深入理解Java虚拟机第三版,总结笔记【随时更新】
最近一直在看《深入理解Java虚拟机》第三版,无意中发现了第三版是最近才发行的,听说讲解的JDK版本升级,新增了近50%的内容。
这种神书,看懂了,看进去了,真的看的很快,并没有想象中的晦涩难懂,毕竟是公认的经典,作者书面描述能力肯定了得。虽然这种书,不会让你的代码能力马上提升,但是真正的让你知其然,还知其所以然。等遇到了这方面的问题,肯定不会像无头苍蝇一样,一头雾水,起码有一定的思路。更多Java、计算机方面的一些好书正在路上,今年一定要好好地提升一下内功。
不过,比如第五章的内容,调优实战,没有充足的实战经验和一些大型项目经验,虽说一些地方能看懂作者在说什么,但是没有一个自己有过经验的实际场景去代入,理解的还是不够充分。
当然看一次肯定不能消化完整,虽然在看的时候就在有道笔记上做了一些笔记,但是还是上传到博客园吧,就当水一篇博客啦。
Java内存区域与内存溢出异常
2.2运行时数据区域
Java虚拟机所管理的内存包含以下几个运行时数据区域:
1.程序计数器:是当前线程所执行的字节码的行号指示器。就是通过改变这个行号指示器的值来选取下一个需要执行的字节码指令,从而可以实现循环、跳转、分支、异常处理等基础功能。Java虚拟机的多线程是通过线程间的轮流切换、粉配处理器执行时间来实现的,所以为了让线程切换后恢复到正确的执行位置,每个线程的计数器是独立的,互不影响,包括主线程。如果线程执行的是Java代码,计数器记录的是字节码的行号,如果执行的是本地方法,计数器为空。Undefined。这个区域不会报内存溢出异常。
2.Java虚拟机栈:其也是线程私有的,生命周期与线程相同,其描述的是Java执行的线程内存模型。每个方法被执行时会创建一个栈帧(一种数据结构),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。(其中动态链接是:在类加载机制中,解析步骤会把符号引用转为直接引用,还有一部分会在执行过程中才变成直接引用,这就是动态链接)。栈帧的入栈到出栈就是一个方法完整的执行过程。重点是虚拟机栈中的局部变量表,其存放的是基本数据类型、对象引用、returnAddress类型(一条字节码的地址)。当线程请求的栈的深度大于虚拟机允许的深度,会报StackOverflowError。当栈拓展时无法申请到足够的内存会报OutOfMemoryError。
3.本地方法栈:其与Java虚拟机栈作用相似,只是Java虚拟机栈为Java方法服务,而本地方法栈为本地方法服务。也有上面两种异常。
4.Java堆:虚拟机管理的内存中最大的一块。Java堆是所有线程共享的,其唯一目的就是存放对象实例。一个对象的创建,其引用放在栈,实例放在堆。Java堆是垃圾收集器管理的内存区域,因此有的人称他为GC堆。GC相关内容后面再记。无论堆这么划分,其存储的都只是对象的实例,细分的目的只是为了更好的回收内存和分配内存。当Java堆无法完成实例分配,堆也无法拓展,会报OutOfMemoryError。
5.方法区:与Java堆一样,是线程共享的内存区域。用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。如果方法区无法满足内存分配需求,将会报OutOfMemoryError异常。
2.3HotSpot虚拟机对象探秘
对象创建的基本过程:
- 检查new这个指令的参数是否能在常量池中找到一个符号引用,并且检查该引用代表的类是否已经被加载、解析、初始化过。
- 为新生对象分配内存。这个内存分配涉及到的东西很多,比如不同的垃圾收集器也会有不同的分配方法,如果是带压缩整理过程的收集器,其分配起来较为简单,因为其内存空闲地址是连续的。但是在并发情况下,也是不安全的,这时候可能就要采用CAS。这里只讲大概过程,具体细节后面章节会讲。
- 内存分配完成后,虚拟机将分配到的内存空间都初始化为0值。
- 初始化后,虚拟机会进行一些必要的设置,比如这个对象是哪个类的实例、在GC中分代年龄信息等,这些信息会被放到对象头中。
- 上面四步,对于虚拟机来说,一个对象的创建已经完成,但对于程序的角度来说,还差一步,就是类的初始化方法,构造函数。程序员可以按照自己的需求来写这个初始化方法,到这里,这个对象完全被创建完成。
对象在内存当中可以被分为三个部分:对象头、实例数据、对其填充
对象头部分包括两类信息,第一类信息存储对象本身的运行时的数据,如:哈希码、GC分代年龄、线程持有的锁、锁状态、偏向时间戳、偏向线程ID等。
另一部分是类型指针,即通过这个指针确定这是哪个类的实例。如果是数组,还会存储数组的长度。
实例数据部分是对象真正存储的有效信息,即我们定义的各种类型的字段内容。
对其填充没什么实际意义,HotSopt的内存管理要求任何对象的大小必须是8的整倍数,对象头已经被设计为8的整倍数,但是实例数据就不一定了,这时候就需要对其填充来补全。
对象的访问定位:
Java堆是存放实例数据的,Java栈上的reference数据是存放这个实例的引用的。而这个引用主流有两种实现方法:
1.使用句柄,Java堆可能会划分一块内存空间来作为句柄池,reference存储的就是对象句柄的地址,这个句柄包含了对象的实例数据和具体地址信息。优点:不用改变reference,如果GC让对象地址改变,只改变句柄中的地址就行了。
2.使用指针直接访问,reference存储的就是对象的地址,如果只是访问对象本身的话,就不需要像句柄那样再次访问一个地址了。优点:不用二次指针定位。
垃圾收集器与内存分配策略
程序计数器、Java虚拟机栈、本地方法栈这三个区域都是随着线程生而生、随着线程灭而灭。这几个区域不需要考虑太多垃圾回收的问题,方法执行完 了、线程结束了,内存自然就回收了。所以重点Java堆和方法区,他们是共享的区域,所以不会随着线程生灭,也有着很多不确定性。
3.2对象已死?
垃圾收集器在对堆进行回收之前,肯定要判断对象实例是否还有没有用,如果在Java程序中一个对象没有任何作用,其自然就需要被回收。下面两种就是目前主流的方法
1.引用计数算法,在对象中添加一个引用计数器,每当一个对象被引用时,计数器加一,当引用失效时,计数器减一。计数器会0时,就是不可能再被使用了。这种算法很多应用都在使用,比如FlashPlayer、Python等。但是在Java中有个问题,引用计数算法却不能解决,就是循环引用。比如两个对象A和B互相引用,即使它们都为null了,或者都没用了,但是它们的计数器依旧不会0。但是Java虚拟机任然可以回收它们,说明Java虚拟机不是使用的这种算法。
2.可达性分析算法,通过一系列称为“GC Roots”的根对象作为起始节点集,注意是集,不只是一个节点。从这些节点出发,走过的路径称为“引用链”,而在这条引用链上的对象,都是不需要回收的存活对象,而不在引用链上的就需要被回收。能当作GC Root的对象有很多种,主要有:所有已经被加载的类、线程当前栈帧的引用、同步锁持有的对象等。具体还要哪些可以看P70。当一个对象不在引用链上时,不代表一定就会被回收。一个对象被回收至少要被标记两次,第一次被标记后,会进行一次筛选,判断这个对象是否有必要执行finalize()方法,如果这个方法之前已经执行过一次,或者这个对象没有重写这个方法,那么就代表需要被回收。如果这个对象重写了finalize方法,且之前没有被执行过,他就会被放到F_Queue队列中,等待执行这个方法,这个方法是有时间限制的,防止执行太过缓慢对系统造成威胁。这时候,这个对象可以在finalize方法里完成自救,即把自己重新和引用链上的某个对象连接起来。
3.2.5回收方法区
Java虚拟机规范没有强制要求这一区域的垃圾收集工作,主要原因是性价比太低,即判断条件高,回收空间少。方法区主要是回收常量和不再使用的类型。对于常量,如果任何对象都没有引用这个常量,那么它就可以回收了。对于一个类型是否需要回收,判断起来就要复杂许多:1.该类的所有实例被回收。2.加载该类的类加载器被回收。3.没有通过反射使用该类。
3.3垃圾收集算法
上一节说到了该如何判断哪些对象需要被回收,这一节讲讲该如何回收这些对象。在这之前需要了解一下分代收集理论,很多垃圾收集器就是基于这个理论去设计的。分代即把Java堆划分出几个不同的区域,然后根据对象年龄分配到不同的区域(年龄是指熬过垃圾收集器的次数)。主要有两个大区域,新生代和老年代。新生代区域的对象,会被频繁回收,如果在新生代熬过一定回收次数后,就会被放到老年代。这样划分的好处就是可以根据不同区域对象的消亡特征设计不同的垃圾收集算法。这就有了后面要说到的一些算法。但是还存在一个隐性的问题,就是跨代引用,即新生代和老年代之间存在引用,那么我们之前说到的GC Roots就不得不包含一些老年代的对象了,加大了一些额外的开销。有一个跨代引用的假说,如果两个对象之间存在引用,它们应该是共存亡的,出现不同代的引用是极少数的,所以也没必要去为了那极少数,而加大一些额外开销。
具体的算法描述可以看书,P77开始。
1.标记-清除算法:通过可达性分析后,找出需要回收的对象,并标记上,对标记的对象进行回收。也可以反过来,标记不需要回收的对象,对没有标记的对象进行回收。主要有两个缺点:一个是执行效率不稳定,其执行效率是随着清楚对象的增加而降低的。一个是会产生空间碎片,因为回收的对象也许是零零散散的,导致空余内存空间也是零散的。
2.标记-复制算法:把内存空间分为两个相等部分,一次只用其中一个部分,每次把存活对象依次规整的放到另一部分,然后再把已使用的那一部分整体回收。其最大的缺点就是将可用内存缩小了一半,空间浪费的太多了。后来IBM有项研究表明,新生代的对象98%熬不过第一次回收,所以针对这一特点,在标记-复制算法的基础上,把新生代分为一个Eden区、两个Survivor区。其内存占比为8:1:1。每次分配内存只使用一个Eden区和一个Survivor区。把这两个区的存活对象复制到另一个Survivor区中,然后再清除Eden和已使用的Survivor。
3.标记-整理算法:这个算法一般用在老年代,标记过程和标记-清除算法一样,但是在清除阶段会把存活对象移到内存一端,然后直接清除到边界以外的内存。但是在移动过程中会STW(即暂停用户线程)。整理和不整理都有好处和坏处,所以侧重点不同,就有了Parallel Scavenge收集器和CMS收集器。
经典垃圾收集器
Serial(Serial Old)收集器:如同其名字一样,他是个单线程收集器,意味着它在工作时,用户线程必须停止,也就是常说的STW。在新生代中它采用的是标记-复制算法,在老年代中采用的是标记-整理算法。虽然这个线程是最基础、历史最悠久的收集器,但是相比较于其他单线程的收集器,它依旧是非常优秀的,在HotSpot虚拟机客户端模式下(Server启动慢,编译更完全,编译器是自适应编译器,效率高,针对服务端应用优化,在服务器环境中最大化程序执行速度而设计;Client启动快速,内存占用少,编译快,针对桌面应用程序优化,为在客户端环境中减少启动时间而优化),新生代的默认收集器就是它。
ParNew收集器:只在新生代中,其只是Serial的多线程版本,其他的与Serial没有太多区别。依旧需要STW,它在新生代采用的是标记-复制算法。是服务端虚拟机新生代的首选收集器。
Parallel Scavenge(Parallel Old)收集器:Parallerl Scavenge是一款新生代收集器,采用的是标记-复制算法。它也是多线程的,但是与其他收集器不同的是它更关注吞吐量(用户代码运行时间/用户代码运行时间+垃圾收集时间)。适合在后台运算而不需要太多交互的任务。Parallel Old是老年代版本,采用的是标记-整理算法。也是注重吞吐量的多线程垃圾收集器。
CMS收集器:老年代的收集器,注重的是减少STW的时间,基于标记-清除算法。其过程相比其他收集器更为复杂,大概分为四步:1.初始标记,根据GC Roots找到直接关联的对象。2.并发标记,根据初始标记阶段的对象找到更为完整的关联对象。3.重新标记,由于并发标记是和用户线程并发的,在这个过程难免会出现一些新的可回收对象。4.并发清理,由于采用的是标记-清除算法,不需要移动对象,所以可以和用户线程并发进行。虽然这是HotSpot追求低停顿时间的一次成功尝试,但是也有一些缺点,比如:并发清除阶段会产生新的垃圾、标记-清除算法产生的内存空间碎片、CMS默认的回收线程是(处理器核心数量+3)/4,对处理器敏感。
G1收集器:是一款主要面向服务端应用的垃圾收集器,作用于整个Java堆,是具有里程碑式意义的。虽然G1依旧保留新生代和老年代的概率,但它们不再是固定的,而是把Java堆划分成多个大小相等的Region区,每个Region区可以根据需要扮演新生代和老年代中的角色,整体来看,他采用的是标记-整理算法,但是在两个Region之间,采用的是标记-复制算法。用户可以设定收集停顿模型,会优先回收价值收益最大的那些Region。其工作过程分为初始标记、并发标记、最终标记、筛选回收。前三个阶段与CMS类似,筛选回收阶段会对各个Region的回收价值和成本排序,根据前面用户的设定来制定回收计划。除了并发标记阶段,其余三个阶段也是需要STW的。G1被称为里程碑式的设计一个重要原因就是,设计者的思想从原来一次性把垃圾收集干净,到只是回收的速度比分配速度快就行了,这样在满足需求的情况下,性能也得到了很大的提升。
总结:从名字看,除了G1作用于整个Java堆,CMS作用于老年代,其余五款都可以根据名字判断出作用于老年代还是新生代。根据之前垃圾收集算法的特点,老年代多用标记-整理算法,新生代多用标记-复制,除了CMS,其作用于老年代是标记-清除算法。而作用在服务端还是客户端,由于服务器多核心CPU较为常见,所以多线程收集器用在服务端更好。
可达性分析
为了方便描述,首先定义一个三色标记,白色就是可达性分析中还未被标记的对象,如果从始至终都是白色那就是需要回收的对象。黑色就是已被标记,它的引用也被扫描过的对象。灰色就是已被标记,但是它的引用还未被扫描的对象。
在并发标记时,对象之间的引用可能会不停变动,当同时出现这两种情况时,本来在引用链上的对象会丢失:1.黑色对象增加了一个到对象A的引用。2.灰色对象删除了到对象A的引用。如果一个对象同时出现这两个情况就会丢失。为什么要同时出现呢?因为已被标记的对象不会回头去检查,而正在被标记的对象如果引用发生变动会马上生效。所以针对这两种情况,只要解决其中一条就不会出现对象丢失的问题。1.增量更新,破坏的就是第一条,黑色对象新增一个引用就会被记录下来,等并发标记结束后,再以记录的对象为根重新扫描一边。2.原始快照:破坏第二条。灰色对象删除一条引用就将这个灰色对象记录下来,并发标记结束后再以记录的对象为根,扫描一边。
低延迟垃圾收集器
垃圾收集器三个重要的指标:内存占用、吞吐量和延迟。内存占用和吞吐量随着硬件性能的提升,帮助了软件不少,不需要那么关注这两点,随着硬件的提升这两项指标也会随着提升。但是延迟不一样,延迟也就是STW的时间,随着内存条的容量越来越大,Java堆可用的内存也越来越大,意味着需要回收的空间也越来越大,那么STW也就越久。
Shenandoah收集器:是一款非官方的垃圾收集器,是由RedHat公司开发的项目,受到来自Sun公司的排斥,所以在正式商用版的JDK中是不支持这个收集器的,只有在OpenJDK才有。虽然没有拥有正统血脉,但是在代码上它相较于ZGC更像是G1的继承者,在很多阶段与G1高度一致,甚至共用了一部分源码,但相较于G1又有一些改进。最主要有三个改进:
1.支持并发标记-整理算法。
2.默认不适用分代收集,Shennandoah和G1一样使用Region分区,但是在Shennandoah中并没有Region会去扮演新生代或者老年代。
3.G1中存储引用关系的记忆集占用了大量的内存空间,在Shennandoah改用为连接矩阵,具体可以看P107。
Shennandoah收集工作过程大概可以分为9个步骤:
1.初识标记:与G1一样,标记处与GC Roots直接关联的对象,STW。
2.并发标记:与G1相同,根据上一步的对象,完整标记出可达对象。
3.最终标记:也与G1一样,利用原始快照的方法标记出上个阶段变动的对象,还会在这个阶段统计出回收价值最高的Region,组成一个回收集。
4.并发清理:这个阶段会清理整个Region区一个存活对象都没有的区域,所以可以并发进行。
5.并发回收:将回收集中存活的对象复制一份到其他未被使用的Region区中。
6.初始引用更新:并发回收阶段复制后,还需修正到复制后的新地址,但这个阶段并未做什么具体操作,只是相当于一个集合点,确保并发回收阶段所有线程都完成了自己的复制工作。
7.并发引用更新:这个阶段才是真正修正引用的阶段。
8.最终引用更新:上一步只是修正了Java堆中对象的引用,还要修正存在于GC Roots的引用,最后一次短暂的暂停,只与GC Roots数量有关。
9.并发清理:经过了并发回收的复制和引用修正,会收集中的Region就可以完全清理了。
再说说Shennandoah的一个特点,也就是前面说到的并发标记-整理算法。整理阶段可以细分为5,6,7,8四个步骤。其最大的一个问题就是,在复制或者在修正引用的时候用户线程可能正在使用这个对象。原来有个解决类似问题的方案,就是保护陷阱,大概过程就是当用户线程访问到对象就地址后,会进入一个异常处理器中,由该处理器转发到新的地址。而在Shennandoah中用的是一种相对更好的方案:转发指针,就是在每个对象前面加个新的引用字段,当不处于并发移动的情况下,该引用指向自己,并发移动了的话就指向新地址。
ZGC收集器:ZGC的目标和Shennandoah相似,都希望在不影响吞吐量的情况下,将停顿时间限制在10毫秒以内。ZGC也是基于Region布局的,还并未支持分代收集,但其Region有大中小三个类型:
1.小型Region容量固定为2MB,用于放置小于256KB的小对象。
2.中型Region固定容量为32MB,用于放置大于等于256KB,小于4MB的对象。
3.大型Region容量不固定,但一定是2的整倍数,用于存放大于4MB的对象。
ZGC在实现并发整理时用到了染色指针,之前的的收集器如果想在对象中额外存储一些信息,大多会在对象头里存储,比如转发指针。再就是之前说到的可达性分析中的三色标记,其只是表达了对象引用的情况,跟对象本身的数据没任何关系,所以染色指针就是把这些标记信息记录在引用对象的指针上。指针为什么还能存储信息呢?这就要说到系统架构了,具体看P114,染色指针只支持64位系统,而AMD64架构中只支持到了52位,而各大操作系统又有自己的限制,染色指针在Linux支持的46位指针宽度中,拿出4位存储这些标记信息,所以使用了ZGC进一步压缩了原本46位的地址空间,从而导致了ZGC能管理的内存不能超过4TB,在今天看来,4TB的内存依旧非常充足。
染色指针的三大优势:
1.一旦某个Region的存活对象被移走后,这个Region立即就能被回收重新利用,而Shennandoah需要一个初始引用更新,等待所有线程复制完毕。
2.染色指针可以大幅度减少在垃圾收集过程中内存屏障的使用数量(后面过程中的第五步提到),一部分功能就是因为染色指针把信息存储在指针上了,还有一部分原因就是ZGC还并未支持分代收集,所以也不存在跨代引用。
3.染色指针在未来可以拓展,记录更多信息,前面说到在64位系统中,Linux只用到了46位,还要18位未被开发。还有一个问题就是染色指针重新定义指针中的几位,操作系统 是否支持,虚拟机也只是一个进程而已,这里就用到了虚拟内存映射,具体看P116。
ZGC工作过程大概可以分为以下几步:
1.初始标记:与之前几个收集器一样,找到GC Roots的直接关联对象。
2.并发标记:标记出完整的可达对象,与G1和Shennandoah不同的是,它是在指针上做更新而不是对象头。
3.最终标记:和Shennandoah一样。
4.并发预备重分配:这个阶段需要根据特定的查询条件统计出本次收集过程要清理哪些Region。这里的分配集不是像G1那样按收益优先的回收集,分配集只是决定了里面的对象会被复制到新的Region,这里的Region要被释放了。
5.并发重分配:这个过程要把分配集中的对象复制到新的Region中,并为分配集中的每个Region维护一个转发表,得益于染色指针的帮助,可以仅从引用上就可以得知某个对象是否在分配集上,如果在复制时,一个用户线程访问了分配集中的对象就会被内存屏障截获,然后根据转发表将访问转发到新的对象上,并修正这个线程访问该对象的引用,这个过程称为指针的自愈。
6.并发重映射:这个阶段要修正整个堆中指向重分配集中旧对象的所有引用。这个阶段比较特殊,因为它不是迫切需要去执行的,上个阶段的自愈过程就是针对某一对象的引用修正,所以即使没有这一步也不会出现问题,只是第一次自愈有个转发过程会稍慢一点,后面也都正常了。正因为这种不迫切,ZGC巧妙的把这步工作合并到了并发标记过程当中,因为并发标记也需要遍历所有对象,这一步也需要修正所有旧对象的引用。
ZGC的一大问题就是其暂时还没有分代收集,这限制了它能承受的对象分配速率不会太高。如果长时间的回收速率比不上分配速率,产生的浮动垃圾越来越多,可分配的空间也越来越小了。所以要从根本上解决这个问题还是要引入分代收集,让新生代专门去存储这些频繁回收创建的对象。
虚拟机性能监控、故障处理
- jps:虚拟机进程状况工具,可以列出正在运行的虚拟机进程。选择参数:-l:进程主类全名;-v:虚拟机进程启动时的参数。-m:进程启动时传给main函数的参数;
- jstat:监视虚拟机各种运行状态信息的命令行工具。可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据。选择参数:-class:监视类加载、卸载数量、总空间等。-gc:监视Java堆状况。更多选项看P142
- jinfo:实时查看和调整虚拟机的各项参数,上面说到的jps -v可以看虚拟机启动时显式指定的参数,虚拟机默认的参数可以用jinfo -flag查看。
- jmap:用于生成堆转储快照(是一个Java进程在某个时间点上的内存快照。Heap Dump是有着多种类型的。不过总体上heap dump在触发快照的时候都保存了java对象和类的信息。通常在写heap dump文件前会触发一次FullGC,所以heap dump文件中保存的是FullGC后留下的对象信息)。还可以查询finalize执行队列、Java堆和方法区的详细信息。选项参数看P144。常见如:-dump:生成转储快照;-finalizerinfo:查看finalize执行队列;-heap:显示Java堆详细信息。
- jhat:这个命令与jmap搭配使用,用于分析jmap生成的堆转储快照。
- jstack:用于生成虚拟机当前时刻的线程快照(线程快照是虚拟机内每一条线程正在执行的方法堆栈的集合)。生成线程快照的目的通常是因为线程长时间停顿,如线程间死锁、死循环、请求外部资源导致的长时间挂起等。选项:-F:当正常的输出请求不被响应时,强制输出线程堆栈。-l:显示关于锁的附加信息;-m:如果调用了本地方法,可以线程C/C++的堆栈。
虚拟机类加载机制
7.2类加载的时机
一个类从被加载到卸载出内存,一共要经过七个阶段:加载-连接(包括:验证-准备-解析)-初始化-使用-卸载。
一个类什么时候进行加载并没有强制约束,但是初始化有且只有六种情况下才能进行,如使用new关键字实例化对象时、读取一个静态字段时、通过反射调用一个类时、子类要初始化时父类必须也初始化等,详细的见P264.总结来说,就是对类型的主动引用,才会去进行初始化。用到的时候才去初始化,这也符合我们的正常思维。当然初始化前肯定要进行前面几步,但是什么时候加载是没有限制的。
7.3类加载的过程
1.加载:这个加载是整个流程的第一步,与标题的类加载不是同一个意思。这一步主要做三件事:1.1获取此类的二进制字节流。1.2将字节流中代表静态存储结构转化为方法区的运行时数据结构。1.3生成一个代表这个类的Class对象,作为方法区这个类各个数据的访问入口。Java虚拟机规范并没有对这三件事做很严格的要求,比如获取二进制字节流,并没有要求一定要从Class文件中获取,所以有了现在的jar包、war包等从压缩文件中读取。也可以从其他文件里读取,比如jsp文件。
2.验证:这个阶段非常重要,工作量也在整个流程当中占相当大一部分。这一阶段要确保字节流中的信息符合规范要求,不存在危害虚拟机的代码。如果仅在Java代码层面,是很难做出不合规范的操作,比如访问数组边界外的数据等等,编译器都会抛出异常,拒绝编译。整个阶段主要有以下四个检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。文件格式验证:验证字节流是否符合Class文件格式的规范,比如是否以魔数0xCAFEBABE开头、代码版本号是否在当前虚拟机接受范围内等。元数据验证:主要是对类的元数据信息进行语义验证,保证要符合Java语言规范。比如这个类是否有父类,除了Object,每个类都应该有父类等。字节码验证:对方法体进行校验,保证方法在运行时不会危害虚拟机,如保证在任何时候都不会跳到方法体以外的字节码指令上等。符号引用验证:这个验证主要是确保解析行为能正常执行,这个验证会发生在解析阶段,即符号引用转为直接引用,如检查符号引用中通过全限定名是否能找到对应的类等。
3.准备:这个阶段是为类变量分配内存(方法区)和设置初始值。注意这里说的是类变量,即被static修饰的,而不包括其他的变量,其他的变量会在这个类实例化时随着这个类的对象一起分配。还有一点就是,初始值是零值,也就是一些基本数据类型的默认值,比如int是0,即使语句如下:static int value=123;初始化是0,而不是123.除非是常量,如static final int value=123,那么它的初始值就是123.
4.解析:这个阶段是将常量池内的符号引用替换为直接引用。符号引用:用一组符号来描述所引用的目标,可以是任何形式的字面量,只要能定位到目标即可。直接引用:可以是直接指向目标的指针,或者一个句柄,总之就是能直接定位到目标,而且只要有了直接引用,那么虚拟机内存中一定就有该引用目标。
5.初始化:是类加载的最后一步,在准备阶段时已经为变量设置了初始零值,这个阶段会根据程序代码初始化变量和其他资源。这个阶段才从我们编码角度进行真正的初始化。初始化阶段其实就是执行类构造器<clinit>()方法的过程。这个方法并不是由程序员去编写的,而是Javac编译器自动生成的。是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并生成的,收集顺序就是代码的编写顺序。因此<clinit>()方法也不是类和接口必须的,如果一个类中没有类变量的赋值语句和静态语句块,也就不会有这个方法了。如果一个类生成了这个方法,即使是多个线程同时调用了,执行<clinit>()也只能有一个线程,其他线程会被阻塞。
7.4类加载器
Java虚拟机的设计团队有意把类加载阶段中获取一个类的二进制字节流这个动作放到Java虚拟机外部去实现。而实现这个动作的代码被称为“类加载器”。在Java虚拟机的角度看,只有两种两种不同的类加载器:1.启动类加载器,使用C++实现,是Java虚拟机的一部分。2.其他所有类加载器,这个类加载器由Java语言实现,独立存在Java虚拟机之外,并且要全部继承启动类加载器。但是在Java开发人员的角度看,类加载器应该被分的更细一点,自JDK1.2以来Java一直保持这三层类加载器、双亲委派的类加载架构。下面说到的是JDK8及之前版本的三层类加载器和双亲委派模型。
启动类加载器:这个类加载器负责加载存放在JAVA_HOME\lib目录、或者被-Xbootclasspath参数指定的路径的类库。并且是Java虚拟机能够识别类库,即按名称识别,如果名称不符合要求,即使在这个目录中也不会被加载。
扩展类加载器:这个类加载器是Java代码实现的。负责加载<JAVA_HOME>\lib\ext目录中、或被java.ext.dirs系统变量指定的路径中所有的类库。就如其名,用户可以将一些通用的类库放到ext目录中,以拓展JAVA SE的功能。
应用程序类加载器:负责加载ClassPath即用户路径下所有的类库,如果没有自定义的类加载器,一般情况下,这将是默认的类加载器。
加上用户自定义的类加载器,各个类加载的协作关系通常如P283的图所示,即从自定义类加载器——>应用程序类加载器——>扩展类加载——>启动类加载。层层递进。而这种关系,被称为类的双亲委派模型。
双亲委派模型的工作过程如下:一个类加载器收到加载一个类的请求后,自己不会去加载,而是请求委派给父类加载器,如上箭头一样,层层递进,直到父类无法完成这个加载请求时(它的执行目录下没找到该类)自己才会去加载。
这么做的好处就是,如果用户自己编写一个与Java类库中重名的Java类,比如Object类,而各加载器都各自加载,那系统中就会出现多个不同的Object类,后果肯定是混乱的。有了双亲委派模型后,Object类会一直被委派到启动类加载器中去执行,如果这个时候用户再写一个Object类,最后也到达启动类加载器时,会先根据类名判断这个类是否被加载过,如果被加载过就不再加载。可以看P284双亲委派模型的实现源码,第一句就是根据name判断是否加载过,很好的杜绝了上面的那个问题。
Java内存模型与线程
12.3内存模型
首先要明白一点,这里所说的Java内存模型与前面说到的Java堆、Java栈等不是一个层次的对内存的划分。Java堆等区域是Java虚拟机所管理的内存中运行时的数据区域。其实这所有的划分在物理机角度上是不存在的,只是逻辑上的划分,是Java虚拟机为了方便管理内存而设计的,就像Java堆里还分为老年代和新生代。看完前面的章节知道了运行时的几个数据区域的作用非常多也非常重要,而Java内存模型的主要目的是为了定义程序中各种变量的访问规则。不同的硬件和操作系统它们对内存的访问规则都可能有所不同,为了屏蔽这种差异,就有了Java内存模型。
上面说到的对变量的访问规则,这里的变量并不是指代码里面的所有变量,而是包括成员变量、类变量和构成数组对象的元素。不包括局部变量、方法参数。因为后者是线程私有的,不存在竞争问题。Java内存模型主要分为主内存和工作内存,上面规定的变量都存在主内存中,每条线程有自己私有的工作内存。工作内存中保存着该线程当前操作的变量在主内存中的副本。线程操作变量时,会从主内存复制一份到自己的工作内存,修改完后再把新值赋值到主内存中的变量。
内存间的交互:Java内存模型定义了8种操作来完成,这8种操作都是原子性的。
1.lock:作用在主内存,标识一个变量为线程独占。2.unlock:作用于主内存,把线程独占的变量释放出来。
3.read:作用于主内存,把一个变量的值传输到线程的工作内存。4.load:作用于工作内存,把read到的值放入到副本中。
5.use:作用于工作内存,把工作内存中变量的值传给执行引擎。我们在获取一个变量的值时就是这个操作。
6.assign:把执行引擎传来的值赋值给工作内存中的变量。我们给一个变量赋值时就是这个操作。
7.store:把工作内存中变量的值传给主内存。8.write:把store传来的值放入到主内存对应的变量中。
除了这8种操作,还有一些对这8种操作的规定。如lock标识的变量其他线程不能使用。上面的原子操作往往是需要两个一起配合才能完成一个完整的步骤的,所以还有些规则规定这些原子操作间的配合不能不合逻辑,有冲突。如read后不load,assign后就不管了等。详情看P443。
针对volatile修饰的变量的特殊规则:
volatile有两个作用:1.volatile变量对所有线程是立即可见的,即volatile变量的所有操作都能立即反映到其他线程之中。这是普通变量不具备的,普通变量被一个线程修改后,必须要被该线程传回主内存,而其他线程必须读取主内存中这个变量后才知道这个变量改变了。2.禁止指令重排序优化,指令重排序即处理器会把多条指令分发给不同的电路单元进行处理,有时候这种处理顺序不一定是程序上的顺序,但不会打乱有前后关联的两个指令。比如一个变量A,第一条指令是A+10,第二条指令是A*2,第三条指令是B-3,显然第一条指令和第二条指令不能打乱顺序,而第三条指令跟它们没有任何关联,所以是放在它们前面还是后面都没有影响。
所以这些特殊规则也都是为了满足上述的volatile的两个作用。比如线程use一个变量前必须load,这就是为什么volatile变量是立即可见的;线程执行了assign,才能执行store,这是为了保证每次修改都能同步到主内存中,才能保证其他线程能立即看到改变。详情见P449.
最后一点内容与线程有关,但提到的并不是很多。关于线程的笔记会在《Java并发编程的艺术》中再记。该篇笔记总计一万两千字左右,在看完整本书后,作为理论部分的复习笔记也是不错的。如果以后对Java虚拟机有更深刻或者其他的理解,也会随时更新到这个笔记中。