Java虚拟机相关知识点
作者专注于Java、架构、Linux、小程序、爬虫、自动化等技术。 工作期间含泪整理出一些资料,微信搜索【javaUp】,回复 【java】【黑客】【爬虫】【小程序】【面试】等关键字免费获取资料。技术交流、项目合作可私聊。 微信:shuhao-99999
目录
3.2 集合里面的对象属性被修改,再调用remove()方法不生效
前言
先赞后看,此生必赚!
一、Java内存区域划分
1. Java内存区域到底划分了哪几个区?
大体上有五个分区 ,分别是 堆,方法区,虚拟机栈,本地方法栈,程序计数器、直接内存。其中堆区和方法区线程共享,其他区域线程私有。
2. 每个区具体放了什么?
堆区(新生代、老年代):存放对象和数组,是GC 主要作用区域。
方法区 PermGen(永久代):存放虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。在 JDK 1.8 中, HotSpot 已经没有 “PermGen space”这个区间了,取而代之是一个叫做 Metaspace(元空间) 的东西
虚拟机栈:描述Java方法执行时的内存模型,为执行Java方法服务。栈帧中存放局部变量表、操作数栈、动态变量表、方法返回地址
本地方法栈:描述本地方法Native执行的内存模型,为执行本地方法服务。
程序计数器:为执行字节码指令服务,通过改变计数器值来选取下条指令。该内存区域是唯一一个在Java虚拟机规范中么有规定任何OOM(内存溢出:OutOfMemoryError)情况的区域
直接内存:可以使用Native函数库直接分配堆外内存。避免Java堆和Native堆来回复制数据。
3.是不是所有的对象和数组都会在堆内存分配空间?
不一定,随着JIT编译器的发展,在编译期间,如果JIT经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
例如下面的代码,StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸
上述代码如果想要StringBuffer sb不逃出方法,可以这样写:
二、Java堆内存划分
1.堆的内存划分
堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor
堆大小 = 新生代 + 老年代
默认情况下,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2, Eden : from : to = 8 : 1 : 1 。
2.堆的垃圾回收方式
Java 中的堆也是 GC 收集垃圾的主要区域。GC 分为两种:Minor GC、Full GC ( 或称为 Major GC )。
Minor GC 是发生在新生代中的垃圾收集动作,所采用的是复制算法。
回收过程如下:
当对象在 Eden ( 包括一个 Survivor 区域,这里假设是 from 区域 ) 出生后,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳(上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代。
Full GC 是发生在老年代的垃圾收集动作,所采用的是标记-清除算法。
三、类加载机制
1.类加载的过程
类加载的全过程,加载,验证,准备,解析和初始化这五个阶段。
加载:
1. 通过一个类的全限定名来获取定义此类的二进制字节流;
2. 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构;
3. 在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口
验证:
大致上都会完成下面四个阶段的检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。
准备:
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区进行分配。
解析:
解析阶段是虚拟机将常量池的符号引用转换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行。
初始化:
根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者说初始化阶段是执行类构造器方法的过程。
2.类加载器
2.1. 启动类加载器
负责将存放在 < JAVA_HOME > \lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib中也不会被加载)类库加载到虚拟机内存中。
2.2. 扩展类加载器
扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载< JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
2.3. 应用程序类加载器
应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$App-ClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(classPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认类加载器。
3.双亲委派模型
双亲委托模型的工作过程是:
如果一个类加载器收到了类加载器的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类),子加载器才会尝试自己去加载。
四、对象的创建、存储和定位
1.对象的创建
Java是一门面向对象的编程语言,Java 程序运行过程中无时无刻都有对象被创建出来,在语言层面上,创建对象(例如克隆,反序列化)通常仅仅是一个new关键字而已,例如下面的语句。
其实在在虚拟机中,当遇到上述语句时,其执行过程大致要经历下面几个阶段。
1.1 类加载检查
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。
1.2 为新生对象分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定。为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
1.3 初始化内存空间
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
1.4 对对象进行必要的设置
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
2.对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。
对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如HashCode,GC 分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。这部分数据的长度在32位和64位虚拟机(未开启压缩指针)中分别为32bit和64bit。
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
对齐填充不是必须的,也没有特别的含义,它仅仅起着占位符的作用。之所以会出现对齐填充,是由于HotSpot VM 的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
3.对象的访问定位
建立对象是为了使用对象。我们已经知道,对象的引用保存在Java 虚拟机栈中,而具体的对象实在堆中的。
由于reference类型在Java虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄池和直接使用指针。
通过直接指针访问的方式如下:
这两种对象的访问方式各有优势:
句柄方式:使用句柄访问方式的最大好处就是reference中存放的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改,但是会多一次指针定位的开销;
直接指针方式:使用直接指针访问方式的最大好处是速度快,它节省了一次指针定位的时间开销。
五、垃圾收集机制
Java虚拟机内存划分讲到了Java 内存运行时区域的各个部分,其中程序计数器,虚拟机栈,本地方法栈三个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来是就已知了。因此这几个区域的内存分配和回收都具有确定性,在这几个区域就需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的。而垃圾收集器所关注的是Java 堆和方法区这部分内存。
1.垃圾对象的判定方法
1.1引用计数算法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减一;任何时候计数器为0的对象就是不可能再被使用的。
很多主流的Java虚拟机没有选择使用引用计数法类管理内存,主要原因它很难解决对象之间相互循环引用的问题:
1.2可达性分析算法
这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链。当一个对象到GC roots没有任何引用链相连时,则证明此对象是不可用的。如下图所示,对象object5,object6,object7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
2.垃圾收集算法
2.1 标记-清除算法
标记阶段:先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程其实就是上述的可达性分析算法中的标记过程。它是最基础的算法,后续的收集算法都是基于这种思路并对其不足进行改进而得到的。
不足之处主要有两个:
1.效率问题,标记和清除两个过程效率都不高。
2.空间问题,标记清除之后会产生大量不连续的碎片,可能会导致后续程序需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
2.2 复制算法
在上述标记-清除算法的基础上,为了解决效率问题,复制算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动对顶指针,按顺序分配内存即可,实现简单,运行高效。
缺点就是,将内存缩小为原来的一半,代价较高;当对象存活率较高时就要进行较多的复制操作,效率将会变低。
2.3 标记-整理算法
复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以老年代一般不能直接选用这种算法。标记-整理算法(Mark-Compact)的标记过程与”标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
3.与清理相关的方法
3.1 gc()
对于程序员来说,GC基本是透明的,不可见的。运行GC的函数是System.gc(),调用后启动垃圾回收器开始清理。
但是根据Java语言规范定义, 该函数不保证JVM的垃圾收集器一定会执行。因为,不同的JVM实现者可能使用不同的算法管理GC。通常,GC的线程的优先级别较低。
JVM调用GC的策略也有很多种,有的是内存使用到达一定程度时,GC才开始工作,也有定时执行的,有的是平缓执行GC,有的是中断式执行GC。但通常来说,我们不需要关心这些。除非在一些特定的场合,GC的执行影响应用程序的性能,例如对于基于Web的实时系统,如网络游戏等,用户不希望GC突然中断应用程序执行而进行垃圾回收,那么我们需要调整GC的参数,让GC能够通过平缓的方式释放内存,例如将垃圾回收分解为一系列的小步骤执行,Sun提供的HotSpot JVM就支持这一特性。
3.2 finalize()
finalize()是Object类中的方法。
了解C++的都知道有个析构函数,但是注意,finalize()绝不等于C++中的析构函数。
Java编程思想中是这么解释的:一旦GC准备好释放对象所占用的的存储空间,将先调用其finalize()方法,并在下一次GC回收动作发生时,才会真正回收对象占用的内存,所以一些清理工作,我们可以放到finalize()中。
该方法的一个重要的用途是:当在java中调用非java代码(如c和c++)时,在这些非java代码中可能会用到相应的申请内存的操作(如c的malloc()函数),而在这些非java代码中并没有有效的释放这些内存,就可以使用finalize()方法,并在里面调用本地方法的free()等函数。
所以finalize()并不适合用作普通的清理工作。
但是从很多方面了解,该方法都是被推荐不要使用的,并被认为是多余的。
六、内存溢出和内存泄露分析
1.内存溢出
内存溢出:OOM(OutOfMemoryError)异常,即程序需要的内存超出了虚拟机可以分配内存的最大范围。在Java 虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其他区域都可能发生OOM异常。
2.内存溢出区域
2.1 Java 堆溢出
Java 堆用于存储对象实例,只要不断地创建对象,并且保证垃圾回收机制清除这些对象,那么在对象数量达到最大堆限制就会产生内存溢出异常。
测试方案:无限循环new对象实例出来,在List中保存引用,防止GC回收,最终会产生OOM ,异常堆栈信息并提示Java heap space。
2.2 虚拟机栈和本地方法栈溢出
关于虚拟机栈和本地方法栈,Java虚拟机规范中定义了两种异常:
StackOverflowError异常:
单线程条件下,通过不断递归调用方法,如不断累加的方法,如下所示
最终会产生StackOverflowError栈溢出异常;
OutOfMemoryError异常:
多线程条件下,无限循环地创建线程,并为每个线程无限循环的增加内存,最终会导致OutOfMemoryError异常。
2.3 方法区和运行时常量池溢出
运行时常量池是方法区的一部分。方法区用于存放Class的相关信息,如类名,访问修饰符,常量池,字段描述,方法描述等。
测试方法:
2.4 本机直接内存溢出
此类内存溢出一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果发现OOM之后Dump文件很小,而程序中又直接或间接使用了NIO,可以考虑一下是不是这方面原因。
3 内存泄露
内存泄漏是指无用对象(不再使用的对象)持续占有内存或无用对象的内存得不到及时释放,从而造成内存空间的浪费称为内存泄漏。内存泄露有时不严重且不易察觉,这样开发者就不知道存在内存泄露,但有时也会很严重,会提示你OOM。
Java内存泄漏的根本原因是长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄漏,尽管短生命周期对象已经不再需要,但是因为长生命周期持有它的引用而导致不能被回收。
3.1 静态集合类引起内存泄漏
像HashMap、Vector等的使用最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,他们所引用的所有的对象Object也不能被释放,因为他们也将一直被Vector等引用着。
3.2 集合里面的对象属性被修改,再调用remove()方法不生效
例如:
3.3 监听器
在java 编程中,我们都需要和监听器打交道,通常一个应用当中会用到很多监听器,我们会调用一个控件的诸如addXXXListener()等方法来增加监听器,但往往在释放对象的时候却没有记住去删除这些监听器,从而增加了内存泄漏的机会。
3.4 各种连接
比如数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接,除非其显式的调用了其close()方法将其连接关闭,否则是不会自动被GC 回收的。对于Resultset 和Statement 对象可以不进行显式回收,但Connection 一定要显式回收,因为Connection 在任何时候都无法自动回收,而Connection一旦回收,Resultset 和Statement 对象就会立即为NULL。但是如果使用连接池,情况就不一样了,除了要显式地关闭连接,还必须显式地关闭Resultset Statement 对象(关闭其中一个,另外一个也会关闭),否则就会造成大量的Statement 对象无法释放,从而引起内存泄漏。这种情况下一般都会在try里面去的连接,在finally里面释放连接。
3.5 单例模式
不正确使用单例模式是引起内存泄漏的一个常见问题,单例对象在初始化后将在JVM的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部的引用,那么这个对象将不能被JVM正常回收,导致内存泄漏
七、引用类型
如果一个内存中的对象没有任何引用的话,就说明这个对象已经不再被使用了,从而可以成为被垃圾回收的候选。不过由于垃圾回收器的运行时间不确定,可被垃圾回收的对象的实际被回收时间是不确定的。对于一个对象来说,只要有引用的存在,它就会一直存在于内存中。如果这样的对象越来越多,超出了JVM中的内存总数,JVM就会抛出OutOfMemory错误。虽然垃圾回收的具体运行是由JVM来控制的,但是开发人员仍然可以在一定程度上与垃圾回收器进行交互,其目的在于更好的帮助垃圾回收器管理好应用的内存。这种交互方式就是使用JDK 1.2引入的java.lang.ref包,下图是JDK1.7中ref包的结构层次图。
7.1 强引用(StrongReference)
在一般的Java程序中,见到最多的就是强引用。如Date date = new Date(),date就是一个对象的强引用。对象的强引用可以在程序中到处传递。很多情况下,会同时有多个引用指向同一个对象。强引用的存在限制了对象在内存中的存活时间。假如对象A中包含了一个对象B的强引用,那么一般情况下,对象B的存活时间就不会短于对象A。如果对象A没有显式的把对象B的引用设为null的话,就只有当对象A被垃圾回收之后,对象B才不再有引用指向它,才可能获得被垃圾回收的机会。
7.2 软引用(SoftReference)
软引用在强度上弱于强引用,通过类SoftReference来表示。它的作用是告诉垃圾回收器,程序中的哪些对象是不那么重要,当内存不足的时候是可以被暂时回收的。当JVM中的内存不足的时候,垃圾回收器会释放那些只被软引用所指向的对象。如果全部释放完这些对象之后,内存还不足,才会抛出OutOfMemory错误。软引用非常适合于创建缓存。当系统内存不足的时候,缓存中的内容是可以被释放的。比如考虑一个图像编辑器的程序。该程序会把图像文件的全部内容都读取到内存中,以方便进行处理。而用户也可以同时打开多个文件。当同时打开的文件过多的时候,就可能造成内存不足。如果使用软引用来指向图像文件内容的话,垃圾回收器就可以在必要的时候回收掉这些内存。
在运行上面程序的时候,可以使用 -Xmx 参数来限制JVM可用的内存。由于软引用所指向的对象可能被回收掉,在通过get方法来获取软引用所实际指向的对象的时候,总是要检查该对象是否还存活。
7.3 弱引用(WeakReference)
弱引用在强度上弱于软引用,通过类WeakReference来表示。它的作用是引用一个对象,但是并不阻止该对象被回收。如果使用一个强引用的话,只要该引用存在,那么被引用的对象是不能被回收的。弱引用则没有这个问题。在垃圾回收器运行的时候,如果一个对象的所有引用都是弱引用的话,该对象会被回收。弱引用的作用在于解决强引用所带来的对象之间在存活时间上的耦合关系。
弱引用最常见的用处是在集合类中,尤其在哈希表中。哈希表的接口允许使用任何Java对象作为键来使用。当一个键值对被放入到哈希表中之后,哈希表对象本身就有了对这些键和值对象的引用。如果这种引用是强引用的话,那么只要哈希表对象本身还存活,其中所包含的键和值对象是不会被回收的。如果某个存活时间很长的哈希表中包含的键值对很多,最终就有可能消耗掉JVM中全部的内存。
对于这种情况的解决办法就是使用弱引用来引用这些对象,这样哈希表中的键和值对象都能被垃圾回收。Java中提供了WeakHashMap来满足这一常见需求。
7.4 虚引用(PhantomReference)
在介绍虚引用之前,要先介绍Java提供的对象终止化机制(finalization)。在Object类里面有个finalize方法,其设计的初衷是在一个对象被真正回收之前,可以用来执行一些清理的工作。因为Java并没有提供类似C++的析构函数一样的机制,就通过 finalize方法来实现。但是问题在于垃圾回收器的运行时间是不固定的,所以这些清理工作的实际运行时间也是不能预知的。虚引用(phantom reference)可以解决这个问题。在创建虚引用PhantomReference的时候必须要指定一个引用队列。当一个对象的finalize方法已经被调用了之后,这个对象的虚引用会被加入到队列中。通过检查该队列里面的内容就知道一个对象是不是已经准备要被回收了。
八、java内存模型
8.1 主内存和工作内存
Java内存模型规定了所有的变量都存储在主内存中(Main Memory)中(此处的主内存和介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与前面所讲的处理器高速缓存类比),线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程也无法直接访问对方工作内存中的变量,线程间变量的传递需要通过主内存来完成。
线程、主内存、工作内存的交互关系如下图所示:
九、JVM的生命周期
(1)JVM实例的诞生:当启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public static void main(String[] args)函数的class都可以作为JVM实例运行的起点。
(2)JVM实例的运行:main()作为该程序初始线程的起点,任何其他线程均由该线程启动。JVM内部有两种线程:守护线程和非守护线程,main()属于非守护线程,守护线程通常由JVM自己使用,java程序也可以标明自己创建的线程是守护线程。
(3)JVM实例的消亡:当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者System.exit()来退出。
作者专注于Java、架构、Linux、小程序、爬虫、自动化等技术。 工作期间含泪整理出一些资料,微信搜索【javaUp】,回复 【java】【黑客】【爬虫】【小程序】【面试】等关键字免费获取资料。
参考文献:
[1].https://blog.csdn.net/ylyg050518/category_9266664.html
[2].https://blog.csdn.net/danjuanzi2684/article/details/83387174
[3].https://blog.csdn.net/qq_41701956/article/details/81664921
[4].《深入理解Java虚拟机》周志明 著