学习java虚拟机已经很久了,最近有空,于是将我所知道的一些关于java虚拟机的知识写出来。首先当做是重新复习一下,其次是给想了解java虚拟机的朋友一些参考。笔记内容大量参看《深入理解java虚拟机》这本书。
一、虚拟机内存组成模块
java虚拟机规范中规定了以下组成部分:程序计数器、虚拟机方法栈、本地方法栈(Hotspot中将虚拟机方法栈和本地方法栈合并成方法栈)、java堆、方法区(java8以后将方法区移到了虚拟机外)、运行常量池。
另外java虚拟机还可以额外分配直接内存,不过这不属于java虚拟机内存组成。整体组成如下图:
程序计数器
java虚拟机之所以被称为虚拟机是因为它模仿物理机运行实现的,它的程序计数器也类似于操作系统中的程序计数器,是线程私有的,作用是存储线程将要执行的下一个操作指令(java模仿物理机,也自己实现了多种操作指令)。程序计数器只占用了一小块内存区域。
方法栈和本地方法栈
java中每一次方法调用都对应了方法栈的进栈和出站操作,方法栈中每一个栈帧都对应着java代码中相应的方法调用,栈帧中局部变量表存储了基础数据类型(boolean、byte、char、int、long、float、double、)和reference(reference包括两种:句柄和指针,各自有各自的好处,使用句柄则在改变对象位置时不改变局部变量表里的引用只用改变句柄本身的指针即可,指针的优点则是查询效率快)。
这里有个知识点,实际上Java中的数组是Java虚拟机动态生成的一个对象,不属于基础数据类型,我们常用的数组的length属性其实就是它的对象的一个public属性。
java堆
java堆是虚拟机中最大的内存组成部分,用来存储程序执行中产生的对象(不包括常量、静态常量引用的对象)。java堆会因为垃圾回收以及对应的垃圾回收器的不同而采用不用的划分方式,但整体还是划分为新生代和老年代两个部分。新生代又分为eden区(伊甸区)和survivor区域(幸存区域)。java默认Eden区域是survivor区域的8倍大小(垃圾回收复制算法执行过程统计出来的合适倍数)。不过存在survivor区域又分为两块相同大小的survivor区域:from survivor区域和to survivor区域,作为轮转备用。简单的说,java程序运行中对象就是Eden区域survivor区域和老年代中创建、清理、复制、整理。
方法区
方法区用于存储虚拟机加载的类信息、常量、静态常量等,也被称为永久代。Hotspot在java8之前用永久代来实现方法区,java8后永久代被移出虚拟机内存,使用native memory存储。
运行时常量池
运行时常量池属于方法区的一部分,用来存储常量的值,存储内容分为两种:字面量和符号引用。这个的理解需要结合Class类的前端编译来解读。在虚拟机加载类的时候比如类的名字、字段的名字、常量等的值需要存储下来,而且会频繁使用。Java中的基本数据类型和String类型都可以在虚拟机加载类的时候理解为虚拟机可以描述的值,并不是程序员自己定义的对象。这些值是需要并可以存储在虚拟机中并供后期使用的,这些值便是运行时常量池中的字面量。另外如string.intern()方法也可以在运行期间将一个String的值放到常量池中并返回常量池的引用,只不过这个不是在虚拟机加载类时候放入的。常量池中另一种数据类型是符号引用,这个跟class的结构也是相关的,前端编译阶段,对class结构的描述过程中,一个字面量是可以反复被使用的,于是便可以给字面量编一个索引,在符号引用中引用这个索引去得到值,当然,符号引用本身也会被索引供其他符号引用使用。这个便是常量池中的内容,需要结合对class结构的了解才能更好的理解为什么会有常量池以及常量池中 存储的内容,不能错误的直接理解为我们在开发时在class中自己定义的 “常量”,它包含了我们通常理解的“常量”,但远不止如此。另外,对于我自己常说的自己定义的“常量”,只有static 和 final修饰的基础类型和string类型才属于constantvalue,对象不属于constantvalue。对象的内存是分配在Java堆中,常量是分配在方法区中的运行时常量池中的。举一个常量的特殊性的例子:如在使用ClassA.CONSTANT_VALUEA时,这个时候虚拟机使用的是常量,假如这个时候ClassA还没有被加载,使用这个ClassA.CONSTANT_VALUEA的值时是不会触发ClassA的加载的。
直接内存
java直接allocat出来的内存。NIO使用的缓冲区就是直接内存。
二、虚拟机的垃圾回收
虚拟机的垃圾回收基本可等同于对Java堆的垃圾回收。
虚拟机中判断对象是否死亡的算法——可达性算法
可达性算法的描述非常简单 :对象是否被GC Roots所直接引用,是则存活;是否被GC Roots直接引用的对象所直接或通过其他对象间接引用,是则存活;不满足则被标记为死亡。
以下是从网上找的可达性算法示意:
GC Roots包含:
1.虚拟机栈
2.方法区中的静态属性
3.方法区中的常量
4.本地方法栈
对象的finalize方法
finalize方法经常会在面试中被问到,它提供了类似C/C++中析构函数的功能,当Java中的对象将要被回收时,如果对象有重写finalize方法,那么finalize方法将会被调用一次,当第二次要被回收时则不会被触发调用。我们可以尝试在finalize方法中拯救对象本身不被虚拟机回收,例如将对象被GC Roots引用,那样便可以使对象免于被回收。但是finalize方法并不能一定保证这种操作一定能成功,成功的关键在于finalize方法中的代码执行的要比虚拟机垃圾回收要快,因此finalize方法中拯救对象本身不具备确定性。finalize方法所Java早期为赢得使用者的产物,建议不使用,它完全能被finally和其他方式代替。
垃圾回收算法——标记清除算法
下图是从网上找的标记清除算法的示意图,其原理非常简单:首先对对象的可达性进行标记,然后清除掉不可达的对象。
标记清除的算法的问题是清除之后留下的可用的存储空间非常零碎,当我们需要一个比较大的存储空间来存储大对象时,这将是个灾难。虚拟机不直接使用标记清除算法来回收垃圾,但是标记清除算法是其他优化过的算法的基础。
垃圾回收算法——复制算法
复制算法的原理也很简单:将内存划分为两块相同大小的区域,只使用其中一块,当进行垃圾回收时,将还存活的对象移至另一块内存中,本身则全部清除掉,这样就不会产生内存碎片。
下图是复制算法的示意图,图片来自网上:
我们可以看出,复制算法是基于标记清除算法的思想进行的,复制算法的缺陷是浪费了太多内存,Java虚拟机使用复制算法时当然不会直接这样去做。实际上复制算法是java堆中新生代的基本算法思想(实际上并没有这么直接使用)。
Java虚拟机根据对象的存活时间不同的特点将Java堆分成新生代和老年代。新生代的对象“朝生夕死”,存活时间短,内存重新分配频繁,适合使用复制算法进行垃圾回收。Java虚拟机将新生代划分为eden区和survivor区(survivor区域有两块,一块from区域,一块to区域,轮转备用),对应复制算法需要的两块内存区域。因为经过垃圾回收后剩下的对象其实是少数,所以survivor区域并不需要和eden区域一样大,那样太浪费内存空间,虚拟机默认的大小是eden区域是survivor区域的8倍大小,虚拟机启动时支持配置。
另外,复制算法只是基础,虚拟的不同回收器实际执行时还进行了优化。
垃圾回收算法——标记整理算法
标记整理算法也是基于标记清除算法实现的,不同点是在标记之后不是将对象直接清除,而是将存活对象前移,清除存活对象内存空间之外的内存空间。
下图也是从网上找的示意图:
当内存大对象多,且对象频繁产生死亡的时候,效率是非常低下的,因此不适合新生代的垃圾回收。但是老年代的对象存活率高,内存相对较小,很适合标记整理算法。
三种算法总结:标记清除算法是其他两种算法以及其他优化过的垃圾回收算法的基础,复制算法适用于新生代,标记整理算法适用于老年代,实际上,Java虚拟机也确实是分代进行垃圾回收的。
概念——STW
STW:stop the world。Java虚拟机进行垃圾回收时是需要中断工作线程的执行的,期间Java程序出现了短暂的停顿。当然,现在虚拟机对垃圾回收的不断优化,几乎可以忽略STW时间了。
垃圾回收器——CMS(current mark sweep)回收器
从名字就可以看出CMS回收器是基于标记清除算法的回收器,它的运作过程分为四步骤:
1.初始标记
初始标记的作用是标记出那些被GC Roots直接引用的对象。这个期间或产生短暂的STW时间
2.并发标记
并发标记是同时和用户线程执行的,标记出被所有被引用的对象。不会产生STW。
3.重新标记
并发标记的时间相对长一些,这个期间可能用于用户线程的操作,并发标记的结果可能已经跟实际产生了偏差,重新标记便是纠正这个偏差的。期间会停止用户线程,产生STW。
4.并发清除
并发清除就很好理解了,就是垃圾的清除工作是和用户线程一起进行的,不会导致用户线程的停顿。
在进行以上四步后并不能保证所有的垃圾都被清除掉了,因为用户线程是在并发进行的。遗漏的垃圾对象需要依赖于下次垃圾回收进行清除。
CMS回收器是多线程并发执行的,因此是对CPU敏感的,比较占用CPU资源。
CMS回收器因为是基于标记清除算法的,单纯的进行这种算法也会产生内存碎片。当无法分配大的内存空间时,会导致Full GC来整理内存空间。
垃圾回收器——G1回收器
G1回收器和CMS在过程上有很多类似之处,只是稍有不同,但是两个回收器的目的和实现方式时完全不一样的。
G1回收器更专注于对于CPU资源的使用,充分发挥现代多核超线程CPU的优势。G1收集器不能确切的划分为标记清除算法、复制算法或者标记整理算法,它在原来新生代老年代的基础上将内存划分为多个区域Region,新生代和老年代都是由多个Region组成的集合。同时,它会跟各个Region垃圾的多少对各个Region进行优先级划分,这种将内存化整为零的做法避免来对全部内存的操作。
G1回收器的实现细节远比上面描述的要复杂,但是其过程也可以划分为以下四步:
1.初始标记
2.并发标记
3.最终标记
4.筛选回收
前面3个步骤都和CMS很类似,只不过是分Region进行的,筛选回收则是对所有Region进行筛选,只选择对那些有必要的Region进行垃圾回收。
Minor GC 和 Major GC / Full GC
这三种称呼其实有点混乱,而且也只是对Java虚拟机垃圾回收的一种思考角度,不能代表虚拟机的垃圾回收算法的划分。
Minor GC 和 Major GC、Full GC的分界还是很清晰的。Minor GC是指对年新生代的垃圾回收动作,它的执行频率非常频繁,回收速度也比较快。
Major GC是指对老年代的划分,一般会伴随一次Minor GC,一般速度较慢。Full GC可以理解为对整个堆的垃圾回收,其实和Major GC语意有点重复,它的另一个语意是产生了STW。
对象的一生
我们现在已经知道,从整体来说,对象是被分配在新生代和老年代中,新生代又被分为eden区域和survivo区域。当一个创建时,它优先是被分配在新生代的eden区域的,但是大的对象(默认3M,可以在JVM启动时设置)直接会被分配到老年代。JVM会为每个对象的“年龄”计数。当存在于eden区域的对象经历过一次垃圾回收后,它就被移到survivor区域,同时它的年龄就被+1,当它的年龄达到15(虚拟机启动时可通过参数配置)的时候就会被移到老年代。另外,虚拟机会survivor区域的大小是否充足,如果内存不足,对象也将直接移至老年代。
三、类文件结构
在分析类文件结构前,我们先写一个简单的类:
package me.wxh.clazzstd; public class TestClass { private int m; public static String CLASS_VARIABLE = "我是类变量"; public final static String CONSTANT_VALUE = "我才是常量"; public final static int CONSTANT_INT = 1; public int inc() { return m + 1; } public static void main(String[] args) throws Exception{ System.out.println(CLASS_VARIABLE); System.out.println(CONSTANT_VALUE); System.out.println(CONSTANT_INT); catInt("wuxuehai"); } public static Integer catInt(String intValue) throws Exception{ try { return Integer.parseInt(intValue); } catch (NumberFormatException e) { return 0; } finally { System.out.println("finally 块执行"); } } }
然后我们使用javap -verbose 命令查看它的class文件结构,如下:
/System/Library/Frameworks/JavaVM.framework/Versions/A/Commands/javap -verbose TestClass.class Classfile /Users/wuxuehai/IdeaProjects/algorithm/target/classes/me/wxh/clazzstd/TestClass.class Last modified 2019-4-24; size 1459 bytes MD5 checksum fdc48a22d072179c43e64b1a57226ef1 Compiled from "TestClass.java" public class me.wxh.clazzstd.TestClass minor version: 0 major version: 49 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #16.#48 // java/lang/Object."<init>":()V #2 = Fieldref #6.#49 // me/wxh/clazzstd/TestClass.m:I #3 = Fieldref #50.#51 // java/lang/System.out:Ljava/io/PrintStream; #4 = Fieldref #6.#52 // me/wxh/clazzstd/TestClass.CLASS_VARIABLE:Ljava/lang/String; #5 = Methodref #53.#54 // java/io/PrintStream.println:(Ljava/lang/String;)V #6 = Class #55 // me/wxh/clazzstd/TestClass #7 = String #56 // 我才是常量 #8 = Methodref #53.#57 // java/io/PrintStream.println:(I)V #9 = String #58 // wuxuehai #10 = Methodref #6.#59 // me/wxh/clazzstd/TestClass.catInt:(Ljava/lang/String;)Ljava/lang/Integer; #11 = Methodref #60.#61 // java/lang/Integer.parseInt:(Ljava/lang/String;)I #12 = Methodref #60.#62 // java/lang/Integer.valueOf:(I)Ljava/lang/Integer; #13 = String #63 // finally 块执行 #14 = Class #64 // java/lang/NumberFormatException #15 = String #65 // 我是类变量 #16 = Class #66 // java/lang/Object #17 = Utf8 m #18 = Utf8 I #19 = Utf8 CLASS_VARIABLE #20 = Utf8 Ljava/lang/String; #21 = Utf8 CONSTANT_VALUE #22 = Utf8 ConstantValue #23 = Utf8 CONSTANT_INT #24 = Integer 1 #25 = Utf8 <init> #26 = Utf8 ()V #27 = Utf8 Code #28 = Utf8 LineNumberTable #29 = Utf8 LocalVariableTable #30 = Utf8 this #31 = Utf8 Lme/wxh/clazzstd/TestClass; #32 = Utf8 inc #33 = Utf8 ()I #34 = Utf8 main #35 = Utf8 ([Ljava/lang/String;)V #36 = Utf8 args #37 = Utf8 [Ljava/lang/String; #38 = Utf8 Exceptions #39 = Class #67 // java/lang/Exception #40 = Utf8 catInt #41 = Utf8 (Ljava/lang/String;)Ljava/lang/Integer; #42 = Utf8 e #43 = Utf8 Ljava/lang/NumberFormatException; #44 = Utf8 intValue #45 = Utf8 <clinit> #46 = Utf8 SourceFile #47 = Utf8 TestClass.java #48 = NameAndType #25:#26 // "<init>":()V #49 = NameAndType #17:#18 // m:I #50 = Class #68 // java/lang/System #51 = NameAndType #69:#70 // out:Ljava/io/PrintStream; #52 = NameAndType #19:#20 // CLASS_VARIABLE:Ljava/lang/String; #53 = Class #71 // java/io/PrintStream #54 = NameAndType #72:#73 // println:(Ljava/lang/String;)V #55 = Utf8 me/wxh/clazzstd/TestClass #56 = Utf8 我才是常量 #57 = NameAndType #72:#74 // println:(I)V #58 = Utf8 wuxuehai #59 = NameAndType #40:#41 // catInt:(Ljava/lang/String;)Ljava/lang/Integer; #60 = Class #75 // java/lang/Integer #61 = NameAndType #76:#77 // parseInt:(Ljava/lang/String;)I #62 = NameAndType #78:#79 // valueOf:(I)Ljava/lang/Integer; #63 = Utf8 finally 块执行 #64 = Utf8 java/lang/NumberFormatException #65 = Utf8 我是类变量 #66 = Utf8 java/lang/Object #67 = Utf8 java/lang/Exception #68 = Utf8 java/lang/System #69 = Utf8 out #70 = Utf8 Ljava/io/PrintStream; #71 = Utf8 java/io/PrintStream #72 = Utf8 println #73 = Utf8 (Ljava/lang/String;)V #74 = Utf8 (I)V #75 = Utf8 java/lang/Integer #76 = Utf8 parseInt #77 = Utf8 (Ljava/lang/String;)I #78 = Utf8 valueOf #79 = Utf8 (I)Ljava/lang/Integer; { public static java.lang.String CLASS_VARIABLE; descriptor: Ljava/lang/String; flags: ACC_PUBLIC, ACC_STATIC public static final java.lang.String CONSTANT_VALUE; descriptor: Ljava/lang/String; flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL ConstantValue: String 我才是常量 public static final int CONSTANT_INT; descriptor: I flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL ConstantValue: int 1 public me.wxh.clazzstd.TestClass(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lme/wxh/clazzstd/TestClass; public int inc(); descriptor: ()I flags: ACC_PUBLIC Code: stack=2, locals=1, args_size=1 0: aload_0 1: getfield #2 // Field m:I 4: iconst_1 5: iadd 6: ireturn LineNumberTable: line 14: 0 LocalVariableTable: Start Length Slot Name Signature 0 7 0 this Lme/wxh/clazzstd/TestClass; public static void main(java.lang.String[]) throws java.lang.Exception; descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=1, args_size=1 0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 3: getstatic #4 // Field CLASS_VARIABLE:Ljava/lang/String; 6: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 9: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 12: ldc #7 // String 我才是常量 14: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 17: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 20: iconst_1 21: invokevirtual #8 // Method java/io/PrintStream.println:(I)V 24: ldc #9 // String wuxuehai 26: invokestatic #10 // Method catInt:(Ljava/lang/String;)Ljava/lang/Integer; 29: pop 30: return LineNumberTable: line 18: 0 line 19: 9 line 20: 17 line 21: 24 line 22: 30 LocalVariableTable: Start Length Slot Name Signature 0 31 0 args [Ljava/lang/String; Exceptions: throws java.lang.Exception public static java.lang.Integer catInt(java.lang.String) throws java.lang.Exception; descriptor: (Ljava/lang/String;)Ljava/lang/Integer; flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=4, args_size=1 0: aload_0 1: invokestatic #11 // Method java/lang/Integer.parseInt:(Ljava/lang/String;)I 4: invokestatic #12 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 7: astore_1 8: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 11: ldc #13 // String finally 块执行 13: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 16: aload_1 17: areturn 18: astore_1 19: iconst_0 20: invokestatic #12 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 23: astore_2 24: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 27: ldc #13 // String finally 块执行 29: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 32: aload_2 33: areturn 34: astore_3 35: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 38: ldc #13 // String finally 块执行 40: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 43: aload_3 44: athrow Exception table: from to target type 0 8 18 Class java/lang/NumberFormatException 0 8 34 any 18 24 34 any LineNumberTable: line 26: 0 line 32: 8 line 26: 16 line 28: 18 line 29: 19 line 32: 24 line 29: 32 line 32: 34 line 33: 43 LocalVariableTable: Start Length Slot Name Signature 19 15 1 e Ljava/lang/NumberFormatException; 0 45 0 intValue Ljava/lang/String; Exceptions: throws java.lang.Exception static {}; descriptor: ()V flags: ACC_STATIC Code: stack=1, locals=0, args_size=0 0: ldc #15 // String 我是类变量 2: putstatic #4 // Field CLASS_VARIABLE:Ljava/lang/String; 5: return LineNumberTable: line 7: 0 } SourceFile: "TestClass.java"
接下开,我们利用这两个文件来简单解释下类文件的结构,顺便会涉及到部分class加载的过程解析和虚拟机内存模型的知识。实际上,我们开发过程中并不太可能需要去阅读class文件,但了解class文件的结构有助于我们理解和验证Java虚拟机的执行的过程结构。
常量池——constant pool
类的开始是一些基础的描述,相信并不需要过多的解读,真正需要理解的地方便是从constant pool这里开始,constant pool其实便是我经常所说的常量池。
下面我们来解读下常量池里内容。
常量池中每一行便是一个常量,最左边的#1、#2、#3……是常量的索引。常量分为字面量和符号引用两种,符号引用会引用其他符号引用和字面量最终也可以解析成一个固定格式的值。
“=” 号后的值如“Utf8”、“NameAndType”等都是常量的类型描述,常量的类型有很多种,需要了解更多的可结合资料和书籍去了解,我们只能注重于理解。在这里举一个例子:索引#7的常量是一个String类型的常量,它的第三列是#56,这时我们看第二张图,索引#56的常量是一个“Utf8”类型的常量,表示一个Uft8编码的文本,也就是我们代码里的“public final static String CONSTANT_VALUE = "我才是常量”;”这行中的值,这里编译器是把“我才是常量”这个值生成了一个字面量,然后被#7引用,定义成了一个String类型的符号引用。
第三列是常量的值,如果是字面量,则会是“我才是常量”、1….这样的值,如果是符号引用,则会是对其他常量的索引引用。
最后“//”后面的是对常量的注释,如果是字面量,则没有这一列,如果是符号引用则备注了符号引用的实际值。
常量池在虚拟机中非常重要,javac编译(前端编译)出的字节码中代码中大量引用到常量池中的值,如我们的代码中:
这里的字节码指令中#3、#4….等等都是对常量池中的常量的引用。而前端编译是为了后面的类加载提供的基础的。
字段表
字段表紧跟常量池之后,包含了我们在类中定义的类变量和实例变量,在我们的代码中如下:
我们可以看到它描述了我们定义的CLASS_VARIABLE、CONSTANT_VALUE、CONSTANT_INT 这三个字段,表示出了它们的访问权限、类型、返回值等等。同时在我们的常量池中,我们也可以看到它们的字段名被分别定义成#19、#21、#23这三个Utf8常量。
比较下CLASS_VARIABLE和CONSTANT_VALUE、CONSTANT_INT的区别,我们可以发现后面两个变量多了一个ConstantValue属性,这就是我们之前在介绍虚拟机内存组成模块时介绍常量池时所说的,只有同时被static和final修饰的才是常量,这一点很重要,常量的赋值是虚拟机自动执行,而类变量的赋值是在<clinit>(类初始化)方法中执行,实例变量是在<init>(对象初始化)方法中执行,这就导致我们在其他类中使用常量时,不会触发类的加载。
方法表
方法表存在于字段表之后,我们可以注意到,正如我们才学Java时所知道的一样,当我们没有写构造方法时,编译器会为我们默认实现一个构造方法(虽然当时不知道原理,但确实是这样的):
下面我们用我们代码中的main函数来解析下方法表中方法的构造。main函数的代码如下:
它经过前端编译后的代码如下:
我们来对应着看,首先在字节码的最上部描述出了方法的名称、参数、返回信息、抛出的异常、访问权限等等,这些都非常的直观,我们不多做赘述。
Code属性
Code属性是方法表里核心信息,它将我们方法里的代码描述成Java的字节码指令,然后在虚拟机中执行这些指令便是我们代码的执行(实际上还需要翻译成汇编指令,翻译行为又分为解释执行和编译执行两种)。指令后不带参数的都是对操作数栈(后面会解释到)栈顶元素的操作,带参数的指令需要结合常量池的索引翻译成完整的指令。invoke*这样的指令是对方法的调用,不过方法分很多种,invokevirtual指令是带参数的,但也就是对栈顶对象实例方法的调用,调用栈顶实例的指定方法(我们这里是System.out的println方法,System.out就是我们的栈顶的实例)。这里又个很重要的知识点,invokevirtual这样的调用形式,虽然我们的指令形式都是一样的,但是我们的栈顶对象是可变的,如果我们父类和子类都有同样名字的方法,那么在栈顶的对象是父类还是子类将决定我们调用的实际方法的不同,实际上,这便是Java中多态的实现基础!
简单介绍下这里的指令:
getstatic 访问类字段
invokevirtual 调用虚方法,这只是方法调用的一种,后面我们会知道所有的方法调用指令。
invokestatic 调用静态方法
iconst_1 将int类型的常量1加载到操作数栈
ldc 将一个常量加载到操作数栈
return 方法返回void
LineNumberTable
不知道大家有没有想过这样的问题:为什么我们在debug时候,开发工具能够找到我们对应的代码的源码呢?!有时候我们的class文件和我们的源码版本不一样,那debug时候就乱跑一通?!实际上就是这个LineNumberTable造成的,它的功能非常简单,将方法中的指令的行号和源码中的行号进行对应,这一点我们从截图中看它的形式便能够非常轻松的理。在进行前端编译的时候我们可以选择是否保留LineNumberTable,javac -g:none 选择不保留,javac -g:lines选择保留,当然不保留时,我们就无法从源码中设置断点了。
LocalVariableTable
这是个非常重要的部分,它描述了运行时局部变量表中的变量和源码中变量的关系,直观的给我们展示了Java虚拟机运行时的组成。前面我们在说Java虚拟机内存组成的时候提到过Java虚拟机栈,它的栈贞的每一个元素便包含了一个局部变量表,方法的调用对应着虚拟机栈的进栈操作,调用结束后返回对应着一个出栈操作。这是我们Java虚拟机执行的系统的核心之一!不过这里不是运行时的局部变量表,现在只是前端编译阶段,但是正如我们开始时所说的那样,我们可以从class文件的结构中窥探Java虚拟机运行时的样貌。它和LineNumberTable一样,也不是Java虚拟机执行时必须的,可以在前端编译时选择javac -g:none或者javac -g:vars来选择取消或者生成这个部分,但是Java虚拟机运行时一定会有对应的局部变量表。
这里有个知识点:如果方法是实例方法,那么局部变量表的第一个变量就是this,代表实例本身,这也是为何我们可以在实例方法中可以使用this关键字的原因。
字段表和方法表中还包含了很多其他很重要的属性,但是我们无法写完整,主要原因是:
1.我也不是很了解~
2.那太多啦!
我们只说了几个能很好反应虚拟机运行机制的部分,能够理解虚拟机的运行就达到了我们的目的了。
Java中的异常控制流程——try catch finally在字节码中的体现
下面我们还是先看一下我们的示例代码中的方法:
然后是它转成字节码后的代码:
Java中的异常控制是通过Exception table实现的,以我们代码中的Exception table为例,它定义了try catch finally代码块执行的三个流程:
1.0-8行指令执行,当出现java/lang/NumberFormatException异常时跳转到18行的指令。
2.0-8行指令执行,当出现任何异常时,跳转至34行指令。
3.18-24行的指令执行,当出现任何异常时,跳转至34行指令。
正好对应了try catch finally的语言。
Java中方法的调用指令
invokevirtual 调用虚方法,指调用实例的方法(公共的方法)。
invokeinterface 调用接口方法,在运行时找到实现接口方法的对象,调用对象的合适(方法的重载重写)方法执行。
invokespecial 调用特殊的实例方法:实例初始化方法、私有方法、父方法
invokestatic 调用类方法
invkedynamic 这个比较特殊,是对动态语言的支持,并且在Java编译器中无法看到
Java中方法的调用指令很重要,它搭建出了Java用语言方法调用的基本特性。
四、Java虚拟机类加载机制
类加载的过程
java虚拟机加载类的过程可以细分为以下7个阶段:
加载(Class Loading)
这个过程是指:
1.使用类的全限定名来获取此类的二进制流。
2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3.生成一个代表这个类的lava.lang.Class对象,class对象虽然是一个对象,但是会存在方法区中(HotSpot虚拟机就是这么做的)。
Java虚拟机对类的加载是比较封闭的,字节流的获取是我们少数能控制的部分,因此也产生了很多我们熟知的技术:
从zip包中获取,如我们熟知的jar、ear、war包
从网络中获取,如已经没落的Applet技术
代码动态生成的,如jdk的动态代理cglib技术
由其他文件生成,如jsp(jsp生成的字节流的加载器有些特殊,每一次jsp文件的加载变会生成一个新的加载器,这个加载器生成的目的就是为了被废弃)
假如我们看了jdk的类加载器的代码,就会知道,实际上,每个类加载器都会有一个定义自己管辖的目录的构造方法,然后在这个路径下取字节流。
这里有一个特殊的情况,那就是对于数组的加载,我们知道数组不属于java的基本数据类型,也不是一个简单的引用类型,没有对应的Class,实际上数组对象是由Java虚拟机直接创建的。数组去掉维度后的类型如果是引用类型则会触发这个引用类型的加载数组类型的可见性和引用类型保持一致;如果是基础数据类型,则可见性为public。
验证
这个阶段的目的就是为了保证Class字节流中包含的信息符合虚拟机的要求,并且不会危害到虚拟机的安全。这个过程我觉得只要知道大概意思即可,没有必要过多研究。
准备
准备阶段是正式为类变量分配内存并设置类变量的初始值的阶段,类变量如果是基本数据类型或者String类型的数据,则内存划分是在方法区中进行的。这里我们仍然以我们的之前的字节码文件证明一下:
在我们的字节码文件中的最后部分,我们能看到一个static{}代码块,实际上这个便是虚拟机生成的<clinit>(class init)方法里的指令,用来初始化类。我们可以看到,首先第一条指令ldc是从常量池中获取“我是类变量”这个常量,然后putstatic赋值给#4 (me/wxh/clazzstd/TestClass.CLASS_VARIABLE:Ljava/lang/String;)这个类变量。不过这个是初始化阶段的代码,我们只是用来证明下这种数据类型的类变量的内存划分的位置。
对于实例变量类型的类变量,自然不用说,它一定是在Java堆中划分内存的。
非引用类型的类变量的初始值都是0值,但是我们还是需要注意,常量和类变量的区别,同时被static和final修饰的变量也就是常量的初始值会被直接赋值为对应的ConstantValue的值,这个我们在前面已经说到过了。
解析
解析是将常量池中的符号引用翻译为直接引用的过程。在之前我们已经知道,常量池中包含两种类型的数据:字面量和符号引用。字面量包含基本数据类型和String类型的数值,符号引用引用开其他符号引用或者字面量。但是虚拟机执行不会在执行的时候去翻译这些符号引用,而是在解析阶段就将其翻译为直接引用,即句柄或者指针。
解析过程的触发是在虚拟机指令操作符号引用时触发。
类变量的初始化
根据我的理解,解析和初始化过程是交替进行的,应该没有严格的先后顺序。
再次看一下我们之前的示例的字节码的最后部分:
在之前的准备阶段,我们已经提到,虚拟机会自动生成<clinit>方法,这个方法的作用是初始化类的,它里面的指令内容包含两种,顺序由在源码中出现的先后顺序决定:
对类变量进行赋值,这个我们在实例代码中已经可以清楚的看到。
第二种是源码中的staic代码块中的内容将会被生成到<clinit>方法中。
虚拟机启动时并不会立刻将所有代码里的所有类都加载到虚拟机中,而是在运行时动态加载的,当运行中遇到下面情况时,虚拟机会加载类:
遇到new、getstatic、putstatic、invokestatic这4条指令时,如果类没有加载则出发类的加载过程。注意:使用static final修饰的常量时,并不是使用getstatic指令,并不会触发类的加载。
使用java.lang.reflect包的方法进行反射调用时,如果类没有被初始化
初始化一个类时,发现其父类还没有被初始化,则先初始化其父类
虚拟机启动时main方法所在的类会被优先初始化。
Java支持动态语言时解析出的REF_getstatic、REF_putstatic、REF_invokestatic的方法句柄对应的类没有初始化则先进行初始化。
关于<clinit>方法:
首先它不是必要的,当一个类中既没有类变量,也没有static代码块时,Java虚拟机不会产生<clinit>方法
当执行一个类的<clinit>方法时,如果父类的<clinit>方法还没有被执行,那么就先执行父类的<clinit>方法。
<clinit>方法在并发执行的时候时加锁的。
类加载器:
首先,类加载器的作用是完成类的加载动作的,即类加载的第一个阶段。Java中可以拥有很多个类加载器,其中有虚拟机提供的,也会有自定义的类加载器。每一个类加载器都有其类命名空间,当一个Class的字节码由不同类加载器加载时,那么它们就是不相同的类。
Java中的类加载器和双亲委派模型
虚拟机的类加载器可分为两种:一种是虚拟机提供的加载器——启动加载器,由C++实现;另一种是由Java语言实现的类加载器,它们都继承于抽象类java.lang.ClassLoader。
从另一个维度,按功能划分,我们可以将Java中默认提供的类加载器分为以下几种:
启动类加载器(Bootstrap ClassLoader),它的作用就是将javahome\lib目录下或者指定的-Xbootclasspath目录下的类库按名字查找并加载到虚拟机中。比如我们的rt.jar,如果改名叫其他名字,那么,即使它在以上目录下,那么它也不能正常被加载。它是由C++实现的,是Java虚拟机的组成部分。
扩展类加载器(ExtClassLoader),它是用来加载“java.ext.dirs”系统变量指定目录下的类库的。它是由Java语言实现的。
应用加载器(AppClassLoader),它和ExtClassLoader一样都是在sun.misc.Launcher类中的内部类,负责加载classpath中的指定的类库。
自定义的类加载器。我们自己用java代码实现的类加载器。
加载器的双亲委派模型
如果一个类加载器收到了类加载的请求,它首先不会尝试自己去加载这个类,而是委派给父类加载器去完成,所以每一次加载请求会先传到顶部的启动加载器,当父类加载器无法完成加载请求时子类加载器才会去尝试加载类。加载器双亲委派模型如下:
假如我们自己实现了一个java.lang.Object类,因为有双亲委派模型的存在,类加载请求最终会被转到启动加载器中去,而启动加载器只会在自己管辖的路径里去查找类,所以我们无法自己写一个Object类放到classpath中去替换jdk提供的Object类(实际上我们可以下载Openjdk去修改代码并编译)。
五、虚拟机字节码执行引擎
大学时候我们学习编译原理时候,我们知道程序执行分为两种:解释执行和编译执行。解释执行不提前编译代码,通过解释器去执行代码;编译执行则预先编译好代码产生本地代码去执行,而Java程序运行中时时进行编译和优化的技术叫做JIT。我们将java代码编译成class文件的动作被称为前端编译,后期将class文件的内容编译为本地文件的工作叫做即时编译(JIT)。总体来说,解释执行的有点是启动速度快,而编译执行的优点则是执行效率快。
前面我们在说Java内存模块的时候提到过虚拟机栈,它是Java虚拟机执行的根本构造,它是属于线程的,每个线程都拥有自己的方法栈。虚拟机栈中的一个栈帧对应了一次方法的调用,所有方法的调用在一起便是我们程序的执行! Java中运行时内存模型是工作内存——主内存的模型,主内存负责存储数据,工作内存从主内存获取数据的副本在工作内存中运算,结束后将变量的值存储到主内存 。虚拟机栈就是我们的工作内存,下图展示了栈帧的基本结构:
这个图简单的示意了虚拟机栈的结构,实际上虚拟机栈实现的时候,相邻栈帧的操作数栈和局部变量表会设计成相交的,以实现方法调用的返回值。
局部变量表
这个我们在解释class文件结构时便介绍过,在此我们可以前后照应。局部变量表的作用是存放方法的参数和方法内定义的局部变量,局部变量表的最小单位以solt计算,每一个slot中存放着boolean、byte、char、short、int、float、reference和returnAddress类型的数据,long和double类型的数据则分配两个连续的slot存储。reference就是我们通常说的引用,它分为句柄和指针两种。returnAdress现在不怎么使用了,最初被用来实现异常处理,现在已经被异常表代替。
如果我们读过《effectiv java》这本书,应该会对书中有一章有所印象,这章提到要尽量最小化变量的作用域,在这里我们可以得到印证。因为局部变量表的每个slot是可以复用的减少变量的作用域不仅可以减少局部变量表的长度,而且确定不用的变量会被垃圾回收器回收掉。
如果方法是实例方法,那么局部变量表的第0位存放则是这个实例的reference,也就是我们一直用的this!
操作数栈
前面我们解释class文件方法中的Code属性时介绍过,字节码指令有的是不带参数的,而不带参数的指令则操作的目标则是操作数栈中的数据,例如字节码中的iadd则是将操作数栈栈顶的两个int数据相加,ldc指令则是将常量放入操作数栈的栈顶。
方法的调用
Java中方法的调用指令有以下5种:
invokevirtual 调用虚方法,指调用实例的方法(公共的方法)。
invokeinterface 调用接口方法,在运行时找到实现接口方法的对象,调用对象的合适(方法的重载重写)方法执行。
invokespecial 调用特殊的实例方法:实例初始化方法、私有方法、父方法
invokestatic 调用类方法
invkedynamic 这个比较特殊,是对动态语言的支持,支持了lambda表达式的语法。
在我们之前说的类加载过程的解释过程中,invokespecial和invokestatic指令带的符号引用已经被翻译成方法的入口地址,它们的调用时固定的,因此它们被称为非虚方法的调用。invokeinterface和invokevirtual调用的时候需要根据操作数栈栈顶的对象来获取调用方法的实际入口地址,它们则被称为虚方法的调用。
方法的静态分派
接下来,我们用书上的例子来解释下方法的静态分派:
这个例子的执行结果大家应该没有什么异议的,不论方法的实际类型时什么,虚拟机执行的结果都是“hello,guy”。这是因为两个sayHello方法调用的方法在编译期间已经确定,我们传入的参数woman和man的静态类型(Static Type)都是Human,因此调用的都是参数类型为Human的方法。
我们来看下这个代码编译后调用sayHello方法时的字节码指令:
从后面对#13符号引用的备注我们可以很明显的看到调用的方法在前端编译期已经被确定是参数类型是Human的sayHello方法。这个跟我们后面要说的动态分配可以做个对比,可以很清晰地从字节码层面理解动态分配和静态分配的区别。
方法的静态分配按照静态类型分配是它叫做静态分配的主要原因,不过我觉得这个分配是在编译期已经确定了的也是它叫做这个名字的另一个主要原因吧。
关于方法的重载overload,静态分配会在编译期决定了到底调用哪个版本的代码,不过如果方法的参数个数一致,编译期在确定版本的时候会按照一定的规则来确定死亡(这个规则比较难用语言描述,编译器会选择最合适的版本),例如上面的例子,我们把参数类型为Man的sayHello方法注释掉,然后main方法里man的类型声明为Man,则代码执行的结果会是这样的:
这里,我们把参数类型为Man的方法已经注释掉了,按照静态类型分配,已经无法分配到参数类型为Man的方法了,于是编译期给我们分配到了参数类型为Human的方法。
再用《深入理解Java虚拟机》这本书上的例子来更好地演示下编译期在静态分配上做的最合适的选择:
这里我们重写了很多sayHello方法,当我们调用时使用’a’作为参数时,编译器给我们选择的最优方法是sayHello(char arg),这时候我们看编译后的结果:
方法调用选择的是sayHello:(C)V,这里的C便是char类型。但是当我们把sayHello(char arg)这个方法注释掉,那么编译的结果就会是这样:
编译期把同一段代码编译成了sayHello:(I)V这个不一样的结果(I表示int),有兴趣的可以逐个注释掉这些方法,看看编译器选择的优先级。
方法的动态分派
前面说的静态分派是前端编译期已经决定的,动态分派则不是,它是在虚拟机运行期间对象的实际类型来确定执行的方法的,这也是它名字的由来。
下面我们还是用《深入理解Java虚拟机》这本书上的例子结合编译后的字节码来演示下什么叫动态分派:
注意:我们这里的sayHello方法都是没有参数的,或者说参数一样的,无法通过静态分配区分出。
相信任何Java程序员对这段代码的结果应该都没有异议,这非常面向对象~ 那么接下来我们需要从Java虚拟机的角度来考虑,为什么结果会是这样的!首先,我们还是来看一下这段代码中main函数编译后的字节码:
从字节码的LineNumberTable中可以看出,源码中三句sayHello方法——23行、24行、26行的调用分别对应着Code属性里的16-17行、20-21行、32-33行。这里的一行源码被编译成了两行字节码指令,这是为什么能?
我们之前说过Java虚拟机运行时内存模型时说过操作数栈这个概念,它存储了方法执行过程中的临时数据。aload_<n>这个字节码指令的意思是将局部变量表中的第n个slot中局部变量加载到操作数栈中,对应代码里,就是将我们声明并实例化的man、woman的变量引用存储到操作数栈中(man是第一个生声明的变量,n是1,woman是第二个声明的变量,n是2)。紧接着是invokevirtual指令,它是对实例方法的调用,但我们只看到了它的参数只有一个对方法的描述,却没有对象,对的,它执行的对象就是操作数栈栈顶的变量。
这个时候,我们看到的对方法的描述都是“Method me/wxh/clazzstd/DynamicDispatch$Human.sayHello:()V”,但是却因为栈顶元素的不同,执行了不同的方法。实际上我们在介绍静态分配时举的第一个例子编译的字节码也是invokevirtual,不过那里是同一个对象,()V里面的参数类型限定符的不同,这里是限定符相同,对象不同。
对于invokevirtual指令,它的解析过程大致是这样的:
找到栈顶的第一个元素所指的对象的实际类型,记做C
如果在类型C中找到常量的中描述符和简单名称都相符的方法,则进行方法的权限检验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过则返回IllealAccessError
否则按照继承关系从下往上对C的各个父类进行第2步查找
如果最终没有找到合适的方法,则抛出java.lang.AstractMethodError
顺便我们利用这个代码在验证下我们之前说的Java虚拟机运行时的java虚拟机栈的概念,代码里我们new里三个对象,我们拿第一个new的man的对象来解释下,代码很简单:Human man = new Man();它编译后的字节码指令对应为:
我们来解释下这四行字节码:
0行:new指令创建me/wxh/clazzstd/DynamicDispatch$Man类的实例,这个时候操作数栈栈顶会有一个这个实例的引用。
3行:dup指令将栈顶的元素复制一份再压回栈顶,这个时候操作数栈有两个一个一样的me/wxh/clazzstd/DynamicDispatch$Man类的实例的引用
4行:invokespecial指令调用类实例的<init>方法,操作数栈栈顶出栈,只剩下一个me/wxh/clazzstd/DynamicDispatch$Man类的实例的引用
7行:astore_1指令将操作数栈栈顶的变量存储到局部变量表的第1位(实例方法的第0位是this保留的,可能静态方法也保留了只不过没有值,这个有待考证,不过不影响我们介绍流程),这个时候操作数栈第二个me/wxh/clazzstd/DynamicDispatch$Man类的实例的引用也出栈了。
花了这么多时间来举这个例子,我觉得是非常值得的,通过它,我们知道了局部变量表和操作数栈是如何协同工作的了
动态语言支持
首先对动态语言的理解:拿javascript举例,变量的声明都是“var”,变量是不明确类型的,变量的值才具有类型,方法的调用在运行时才去判断。
之前说过的invokedynamic指令是java对动态语言的支持,但是在java7及以下版本是看不到invokedynamic,并且invokedynamic指令设计的目的也是提高Java虚拟机对动态语言的支持,使得其他在java虚拟机上能支持动态语言的执行。
java7的动态语言支持——java.lang.invoke.MethodHandle
下面是一个java.lang.invoke.MethodHandle的使用例子,来自于《深入理解java虚拟机》这本书:
但是这个类的字节码中我们无法找到invokedynamic指令,有兴趣的可以用javap看一下,不需要用java7去编译。
java8后的lambda表达式
下面我们用一个lambda表达式的例子来看一下invokedynamic指令是什么样子的。
源代码:
javap查看字节码:
第一个红圈对应的是
Arrays.sort(names, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o1.compareTo(o2);
}
});
这个代码,我们可以清楚看到,产生了一个LambdaTest$1这个匿名内部类,并new了一个它的对象。
第二个红圈对应的代码是
list.forEach((String name) -> {
System.out.println(name);
});
这个用lambda表达式产生的字节码,这里我可以看出,编译器产生的是一个invokedynamic指令。
五、虚拟机运行期间优化
首先我们得有个大的概念:虚拟机的优化分为两个阶段,第一个阶段是前端编译阶段,这个期间Java编译器将我们的源码编译成字节码,字节码与平台无关,为后期进一步解释编译提供基础;第二个阶段是运行时解释/编译,这时候Java虚拟机会进一步将字节码翻译成机器码后执行。
学过编译原理,我们都知道,程序的执行分为解释执行和编译执行两种:解释执行是使用解释器执行,不提前编译代码,优点是启动速度快、省去编译的时间,缺点是解释执行的效率相对编译执行会慢很多;编译执行则是提前将代码编译成机器码后执行,相对于解释执行的缺点就是启动慢,优点则是执行效率高。编译器在前端编译期间就在生成字节码上进行了优化,不过这个不是我们要讨论的内容。
主流的Java虚拟机都同时包含解释器和编译期,并不会单一的使用解释执行或者编译器。虚拟机会统计代码的执行频度,当代码反复执行后,虚拟机就知道这段代码是一段“热点代码”,这时候就会对这段代码进行重新优化编译,这种技术就是JIT(Just In Time Compiler)技术。
热点代码:
多次被调用的方法
多次被执行的循环体里的代码块
client模式和server模式
Java虚拟机(HotSpot)在启动的时候可以选择-client以客户端模式启动,-server以服务端模式启动。
Hotspot虚拟机中包含了两个编译器,分别称为Client Compiler和Server Compiler,也分别被简称为C1和C2编译器。虚拟机中一般是默认采用解释器和编译器混合执行的策略。虚拟机启动时可以通过-Xint指定为只使用解释执行,那么虚拟机将完全使用解释执行;-Xcomp指定为编译执行,这时候虚拟机优先采用编译器执行程序,但是在编译器无法进行的情况下进行解释执行。
Server模式在虚拟机中被默认开启。分层编译分为:
第0层,程序解释执行,解释器不开启性能监控,可触发第1层编译
第1层,被称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
第2层,也被称为C2编译,也是将字节码转位本地代码,但是会启用一些耗时比较长的优化,甚至会根据性能监控i 信息进行一些不可靠的激进优化。
六、Java的内存模型(JMM)与线程
前面我们已经提到过java的工作空间和主内存的概念,下图示意里线程、工作内存和主内存的之间的交互关系:
这个内存模型实现了并发变成的基础,非常类似于物理机的内存模型。线程在使用主内存中的数据时,需要先将变量从主内存中拷贝到工作内存中形成变量的副本,然后在工作内存中进行赋值、读取等操作。这种内存模型使线程对数据的操作都是在工作内存中进行的,虚拟机能够将工作内存优先存储于比物理内存更快的高速缓存和寄存器中,从而提高了程序的运行速度。
但是我们思考下,这样做也会产生一个不好的后果:我们同时会有主内存中变量的多个副本,多个线程对器进行读取、赋值操作也就造成,某些副本的值已经失去失效,然后用失效的值进行运算,再将错误的结果写入主内存,这都导致了我们常说的“多线程安全”问题。正式因为出于这个考虑虚拟机则在主内存和工作内存进行交互时定义了一些列规定和协议,正确使用则能保证相对的线程安全(没有绝对的线程安全)。
Java内存模型定义了一些基本操作,这些操作都是原子的、不可再分的(操作不代表指令):
lock(锁定):用于主内存的变量,它把变量标识为一条线程独占的状态。
Unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值传输到线程的工作内存中,以便随后的load动作使用。
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将执行这个操作。
assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接收到的值赋工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码时执行这个操作。
store(存储):作用于工作内存的变量,它将工作中一个变量的值传输到主内存中,以便随后的write操作使用。
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入到主工作内存中。
这些操作本身的意义非常好理解,无非是围绕着变量的锁定解锁、变量值复制、传递、赋值过程进行定义的。围绕着这些定义的基本操作,Java虚拟机规范提出了必须要遵守的规则,然后通过这些规则限定,保重了数据在工作内存和主内存之间交互的线程安全性。
volatile修饰符的语意
volatile修饰符保证了变量读写的多线程可见性
基于上面的知识,我们考虑一下这种情况:在多线程环境下,我们一个线程A从主内存中read、load了一个变量的值,然后另一个线程B store、wtrite修改了主内存中这个变量的值,这时候已经拿了变量值的线程A在use变量副本的值的时候是不知道线程B对变量的赋值操作的。但是如果我们使用volatile来修饰我们的变量,那么过程就不是这样了,虚拟机规范中对volatile修饰的变量作出了以下规定:
线程对volitale变量的副本的load、use操作必须是连续在一起的,也就是说每次使用volitale变量时,肯定是从主内存中最新同步的。
线程对工作内存中的副本变量进行sotre、write操作前的前一条操作必须是assign操作,也就是说,赋值完必须马上存储到主内存中去。
由于这两条规定,虚拟机就保证了线程执行过程对volitale变量的赋值和使用时都是类似于原子的操作,也就保证了它多线程读写的可见性!
volatile修饰符修饰的代码不会被指令重排序
我们知道,Java虚拟机在运行期间会进行代码的优化,中间会对结果不变的多个指令操作进行重新排序,但是这可能对其执行的先后顺序进行了修改,如果我们的线程B的代码依赖于线程A指令执行的先后顺序而产生的结果,那么就可能导致产生错误的判断结果,这个是非常可怕的。如果使用了voatile修饰一个变量,那么虚拟机将不会对volatile修饰的变量进行指令重排序优化。注意:线程A代码依赖于自身代码指令的先后顺序而产生的结果,那么即使进行了重新排序,那么也不会影响判断,这是重排序优化自己做的限制。
免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。