《深入理解Java虚拟机》BR JVM内存管理机制、类结构和加载机制与高效并发      
View on GitHub

龙珠

修炼自己与发现世界

《深入理解Java虚拟机》BR JVM内存管理机制、类结构和加载机制与高效并发

By arthur503 -- 22 Oct 2013

因为想要深入了解一下JVM虚拟机的运行机制,买来这本书看。整体看起来内容写得有一定深度,比较易懂,尤其是内存管理机制,看完比之前了解的更深刻一些;类文件结构和类加载机制讲的也不错,不过由于之前毫不了解,所以所得不多,只有大概了解,等待之后再来温故知新吧;程序编译和代码优化部分,感觉现在我的水平还涉及不到,只是泛泛看了下;高效并发部分讲的不多,主要是JVM内部如何处理并发的,和我需要的如何在编码中处理并发不一致,不过,看了下还是很有益处的。

本文只讲JVM内存管理机制、类结构和加载机制与高效并发这三个部分。

一、JVM内存管理机制

之前其实写博客总结过JVM的内存管理机制,不过那个是《Java编程思想》中讲的,面向Java初学者,虽然涉及到JVM,但并不深入。这次《深入理解Java虚拟机》里讲的更多一些,基本上该涉及到的面都讲全了。

  1. Java内存区域

之前查网络上关于Java内存的资料的时候,经常有人将Java内存区域分为堆内存(Heap)和栈内存(Stack)。这样的分法很粗糙,但大致可以看出Java内存中最重要的两部分。由于Java线程中是单独分配一部分独立内存的,线程隔离的数据区域是依赖用户线程的启动和结束而建立和销毁。因此,我们可以与线程结合,将Java虚拟机运行时数据区进行如下划分:

线程隔离:

  1. 1 程序计数器(Program Counter Register)

进程私有。使用一块较小的内存空间,作用是当前线程所执行字节码(即Java方法等)的行号指示器。

  1. 2 Java虚拟机栈(VM Stack)

线程私有,生命周期与线程相同。用来描述Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。

  1. 3 本地方法栈(Native Method Area)

线程私有。与虚拟机栈的作用相似,区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机用到的Native方法服务。

线程共享:

  1. 4 Java堆(Heap)

Java堆是Java虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例和数组。

Java堆是垃圾收集器管理的主要区域,因此有时也被成为“GC堆”。

  1. 5 方法区(Method Area)

方法区和Java堆一样,是各个线程共享的内存区域。主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然在Java虚拟机规范中,把方法区描述为堆的一个逻辑部分,但它的别名叫Non-Heap(非堆),目的应该是与Java堆区别开来。

在方法区中,运行时常量池中存放编译期生成的各种字面量和符号引用。

垃圾收集器在这个区域比较少出现,主要是针对常量池的回收和对类型的卸载,但一般“成绩”欠佳。

StackOverFlowError和OutOfMemoryError

上次笔试时就有比较这两个Error。对于上面的Java内存数据区域:

  1. 程序计数器占用内存较小,是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域;
  2. Java虚拟机栈和本地方法栈是栈结构,如果线程请求的栈深度大于虚拟机所允许的深度,就抛出StackOverFlowError异常;如果虚拟机栈和本地方法栈可以动态扩展(现在一般都可以),当扩展无法申请到足够的内存时就会抛出OutOfMemoryError;
  3. Java堆、方法区、运行时常量池等,在无法满足内存分配需求(如Java堆中,没有内存完成实力分配,并且堆也无法继续扩展)时,将会抛出OutOfMemoryError;
  4. 另外,直接内存(Direct Memory)并不是Java虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分数据也被频繁的使用,而且也可能导致OutOfMemoryError异常出现。

2. 对象访问

之前因为Java中没有C++中的指针或windows编程的句柄一直很奇怪是怎么引用的,看完这里才知道:Java中对象的访问,是由Java虚拟机来实现对象的访问方式。主流的访问方式由两种:使用句柄和直接指针。

  1. 1 如果使用句柄访问方式,Java堆中会划分出一块内存来做句柄池。reference中存储的地址就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
  2. 2 如果使用直接指针访问方式,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象地址。

二、垃圾收集器与内存分配策略

垃圾收集器其实就是解决三个问题:

1. 哪些内存需要回收?(空间) 2. 什么时候回收?(时间) 3. 如何回收?(方法)

上部分讲过了,Java中实例对象是创建在堆中的,堆也是Java内存的最大部分。因此,实际中,垃圾收集器的主要工作区域就是堆。其他部分的垃圾较少,回收效率较低,我们暂不考虑。

  1. 在Java堆中,哪些内存需要回收呢?

很显然,过期的、死亡的对象没有用处了,就需要回收。那么,进一步的问题就是:如何判断对象死亡?

判断对象死亡的方法有两种:

  1. 1 引用计数算法

这种算法的思想是:如果一个对象不再被别人引用,那么这个对象就是死亡。因此,对每个对象有一个引用计数器,被引用则计数器加一,取消引用则减一。若引用计数为零,则表示对象死亡,可以回收。

这个思想实现简单,判定效率也很高,大部分情况下是一个不错的算法。不过,有个很明显的缺点就是:虽然对象不再被引用时表示对象死亡,但是,对象被引用时不一定不是死亡。也就是说,如果对象被引用,它也可能是死亡的。例如:两个对象互相引用,他们都是死亡的,但各自引用计数器并不为零。因此,Java语言中没有选择引用计数器算法来管理内存。

  1. 2 根搜索算法

根搜索算法的思想是:如果一个对象是活的,那么从根(GC Roots)开始不断向下搜索下去,他就一定会被引用到。这样,没有被引用到的对象就是死亡的。这个算法对“活的”和“死亡”对象的判断更加准确,不会出现如引用计数法中,死亡对象被判断为“活的”对象的错误。

Java语言中,被用作GC Roots的根的对象包括:

* 虚拟机栈中的引用的对象 * 方法区中的类静态属性引用的对象 * 方法区中的常量引用的对象 * 本地方法栈中JNI的引用的对象

从这四个根对象作为起始点,向下搜索,根据判断引用链是否到达来得到对象是否是活的。

另外,需要说明的是,要真正的宣告一个对象死亡,至少要经历两次标记过程:如果对象在根搜索后,发现没有与GC Roots相连接的引用链,则它会被第一次标记并且进行一次筛选,筛选的条件是:如果对象被重写了finalize()方法,并且finalize()方法之前并未被执行过,则会执行finalize()方法,如果在finalize()方法中,对象被其他对象引用,则表示对象“自救”成功,在第二次标记时被移除“即将回收”的集合,于是不会被回收。否则,在第二次标记时,若没能被移除“即将回收”集合,就会在之后被回收。

不过,对于finalize()方法之前博文也讲过,JVM并不保证何时执行,也不保证是否执行,因此,只能作为“有总比没有好”的策略。建议一般不要使用。

  1. Java虚拟机中,何时对内存进行回收?

Java中的垃圾收集器是动态回收内存机制。垃圾收集器作为后台进程一直存活,不过由于进程优先级较低,只有在CPU较闲或内存不够用的时候才会执行。另外,Java中,没有强制进行垃圾回收的方法,只有System.gc()方法可以建议虚拟机进行垃圾回收,至于到底回不回收,何时回收,由虚拟机来决定,用户无法干预。

  1. Java虚拟机中,如何进行内存回收?

Java虚拟机中的垃圾回收算法有三种:

  1. 1 标记-清除算法(Mark-Sweep)

第一阶段,先标记处所有需要回收的对象(标记过程如前所述);第二阶段,标记完成后统一回收所有被标记的对象。

这种算法的思路和实现简单。但主要缺点为:1. 效率。标记和清除的效率都不高;2. 空间问题。标记清除之后会产生大量的空间碎片。

  1. 2 复制算法(Copying)

复制算法将内存划分为大小相等的两块,每次只是用其中一块。当一块内存用完了,就把活着的对象复制到另一半,将此半空间一次清理掉。

这种算法比标记清除算法效率要高,但是代价是:可使用内存所小为原来一半。代价太大。不过,由于新生代对象98%是朝生夕死的,所以并不需要按照1:1划分内存空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可以使用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存是会被浪费掉的。

  1. 3 标记-整理算法(Mark-Compact)

复制收集算法在对象存活率较高的时候就要执行较多的复制操作,效率会变低。因此,有人提出标记-整理算法。

标记整理算法的标记过程仍和标记清除算法相同,不过在整理步骤,不是直接对可回收对象进行清理,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存,这样,摒除了标记清除算法的内存空间碎片的缺点。

  1. 4 分代收集算法(General Collection)

当前商业虚拟机的垃圾收集都采用“分代收集”算法,根据对象的存活周期的不同将内存分为几块。在新生代中,由于存活率较低,可以选用复制算法;在老年代中,由于存活率较高,则使用标记-清理算法或标记-整理算法来进行回收。

最后列一下常见的垃圾收集器,包括:

至于内存分配与回收策略,略过 ,以后再说。可看书中P65页。

三、类结构和加载机制

Class文件时一组以8位字节为基础单位的二进制流。各个数据项目严格按照顺序紧凑的排列在Class文件中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全都是程序运行的必要数据,没有空隙存在。

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。每个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。

局部变量表的容量以变量槽(Slot)为最小单位。虚拟机规范中很“导向性”的说明:每个Slot应该能存放一个boolean, byte, char, short, int, float, reference或returnAddress八种类型。而对于64位的long和double,则与“long和double的非原子性协定”类似,把一次long和double数据类型读写分割成两次32位读写。不过,由于局部变量表是建立在线程的堆栈之上,是线程私有数据,因此,不会引起数据的线程安全问题。

局部变量表中第0位索引的Slot默认适用于传递方法所属对象实例的引用,在方法中可以通过”this”关键字来访问这个隐含的参数。

其他栈帧内容略。

Java具备面向对象的三个基本特征:继承、封装和多态。其中,多态特性的最基本的体现包括:重载(Overload)和重写(Override)。

若Man、Woman是Hunman的子类,则:

Human man = new Man();

中,“Human”成为变量的静态类型或外观类型,“Man”成为变量的实际类型。静态类型的变化仅在使用时发生,变量本身的静态类型不会被改变,最终的静态类型是在编译器可知的;而实际类型变化的结果在运行期间才可以确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

虚拟机(准确的说是编译器)在重载时,是通过参数的静态类型而不是实际类型作为判定依据的。

四、Java内存模型

volatile关键字:

一个变量被定义为volatile之后,它将具有两种特性:

1. 保证此变量对所有的线程的可见性,这里的可见性是指:当第一个线程改变了这个变量的值,新值对于其他线程来说是立即可知的。

注意:这一点常被误解,即:这并不能保证基于volatile变量的运算在并发下是安全的。问题并不是出在volatile变量上,volatile变量在各个线程的工作内存中,每次使用之前都要进行刷新,可以保证一致性。但是,Java中的“运算”并非原子操作,导致volatile变量的运算在并发下一样是不安全的!

因此,volatile只能保证可见性,在不符合一下两条规则的运算中,我们仍然需要使用synchronized或java.util.concurrent中的原子类来保证原子性:

* 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值; * 变量不需要与其他的状态变量共同参与不变约束。

  1. 使用volatile变量的第二个语义是禁止执行重排序优化。

总结而言,volatile变量读操作的性能损耗与普通变量几乎没有什么差别,但是写操作可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不会发生乱序执行。不过即便如此,大多数场景下volatile的总开销仍然要比锁要低,我们在volatile于锁中选择的唯一判断依据仅仅是volatile的语义能否满足使用场景的需求。

long和double的非原子性协定:

Java内存模型中,对于64位的数据类型(long和double),在模型中定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作,这就是所谓的long和double的非原子性协定。

实际开发中,目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此,我们在写代码时一般不需要将用到的long和double变量专门声明为volatile。

五、Java与线程

线程是比进程更轻量级的调度执行单位。线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。

线程实现:

实现线程主要有三种方式:使用内核线程实现,使用用户线程实现,使用用户线程加轻量级进程混合实现。

线程调度:

Java线程调度方式有两种:分别是协同式线程调度和抢占式线程调度。Java中使用的是抢占式。

线程安全:

线程安全强度级别有五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1. 不可变

如加final关键字、String类型、枚举类型、Long和Double等数值包装类、BigInteger和BigDecimal等大数据类型。注意:AtomicInteger和AtomicLong并非不可变的。

2. 绝对线程安全

Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。一般都是使用synchronized关键字,但仍然需要在方法调用端做额外的同步措施。

3. 相对线程安全

这是我们通常意义上讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施。但是,对于一些特定顺序的持续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

Java语言中,大部分的线程安全类是属于这种类型,如:Vector、HashTable、Collections的synchronizedCollection方法包装的集合等。

4. 线程兼容

指对象本身并不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中安全的使用。如:ArrayList和HashMap等。

5. 线程对立

指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。如:已经被抛弃的Thread类的suspend()和resume()方法,还有System.setIn(), System.setOut()和System.runFinalizersOnExit()等。

如何实现线程安全:

1. 互斥同步

这是最常见的一种并发正确性保障手段。包括:临界区、互斥量、信号量等。Java中最基本的互斥同步手段就是synchronized关键字。另外,还可以使用java.util.concurrent包中的重入锁来实现同步。互斥同步是阻塞同步,进行线程阻塞和唤醒会带来性能问题。

2. 非阻塞同步

非阻塞同步的策略是:基于冲突检测的乐观并发策略。即:先进行操作,如果没有其他线程争用共享资源,那操作就成功;否则,若共享数据有争用,产生了冲突,就再进行其他补偿措施(如不断尝试直至成功为止)。这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作被称为非阻塞同步。

另外,虚拟机中还有各种锁优化技术,来保证在线程之间更有效的共享数据,解决竞争问题,从而提高程序的执行效率。


好了,就写到这里,剩下的有不懂的再看书参考吧。

参考资料: