《深入理解JVM》读书笔记

《Understanding the JVM》这本书拿到手后刷了一遍,没过多久就忘得差不多了,为了加深印象和记录所想于此篇笔记当中

参考文档

java虚拟机规范 SE7
Memory management in the Java HotSpot Virtual Machine 白皮书的中文翻译


显式内存管理容易引发的两个问题

  1. 引用挂起:将A对象引用的内存释放掉并分配给B对象,当A再次访问引用的内存时,结果无法预测
  2. 内存泄漏:以单向链表为例,在释放一个链表所占用的内存时如果在处理表头时系统出现了异常,那么整个链表的元素都无法被引用。

    第二章 java内存区域与内存溢出异常

    运行时数据区

    区域名称 | 用处 | 访问性 | 生命周期 |抛出的异常
    —|—|—|—|—|
    PC寄存器 | 指令字节码行号指示器 | 线程私有 | 与线程相同 | 无
    java虚拟机栈(也可以称作java栈) | 存储栈帧(包括局部变量表、动态链接、方法出口)|私有|有线程相同|StackOverflowError:线程请求分配空间过大; OutOfmemoryError:栈动态扩展时无法申请到足够空间
    本地方法栈(也可以称作”C Stacks”)|为执行native方法服务|私有|线程分配|同上
    java堆(也可叫做”GC堆”)|存放所有的对象实例以及数组,垃圾收集器管理的主要区域|共享|虚拟机启动时创建|OOM:所需堆超过了系统能提供的最大值
    方法区(hotspot使用永久代实现方法区)|存放每一个类的结构信息,包括常量池、静态变量、构造函数和不同方法的字节码内容|共享|虚拟机启动时创建|OOM:无法满足内存分配需求
  • 直接内存虽然不是JVM定义的运行时内存,但是在NIO中会使用到
  • 对象在内存中分配到空间后虚拟机会将这块空间初始化为零值,这样保证了对象的属性在java代码中可以不赋初始值就可以直接使用。
  • 粗粒度来说,一台机器上单个线程可分配到的内存(指代虚拟机栈+本地方法栈) = nG(可用该内存上限) - maxHeapSize - maxPermSize(最大方法区容量)。考虑到内存资源紧张的多线程场景,如果想要获取更多的线程的话就需要通过减少堆以及栈空间(-Xss)容量

    本地线程分配缓冲(Thread Local Allocation Buffer),在创建对象时,虚拟机会为该对象分配内存空间,确切的说是从java堆中分配空间,而这一操作在多线程并发的情况下是非线程安全的,除了对内存分配动作进行同步的方案外,还有一种方法就是为每个线程在堆上预分配一块内存(TLAB),这块堆内存空间就暂时变成线程私有的了,如此就将内存分配的动作分配到每个线程私有的空间上进行了,进而避免了并发的问题。

HotSpot代的划分

在HotSpot中,内存被分为3代:年轻代、老年代和永久代(即方法区)。

  • 对象初始分配在年轻代(大对象除外)
  • 对象在年轻代经历几次年轻代的垃圾回收(young generation collections)后升至老年代

年轻代包含3个区域:一个Eden区和两个Survivor区

  • 大部分对象初始分配在Eden区
  • Survivor中保存至少经过一次Young GC的对象
  • 如果To是空的,那么From会一直保留这些对象

    第三章 GC与内存分配策略

    方法论

    判断对象是否可回收的方法

  1. 引用计数算法
    该算法为每个对象添加一个引用计数器,引用了就+1,引用失效了就-1,变为0的对象就可以回收了。
    优点:实现简单,效率高;
    缺点:无法解决相互引用的问题
  2. 可达性分析算法(GC Roots)

    以HotSpot实现的算法为主

  • 该算法从GC Roots出发,向下搜索,遍历的路径为引用链,能遍历到的节点代表对象可达,不必回收;无法遍历到的对象不可达,可回收。
  • 可作为GC Roots的包括全局性的引用(如常量和类静态属性)和执行上下文(如栈帧中的本地变量表)
  • 数据一致性问题:GC检查好比集合点名,需要对象停止当前的活动,否则在检查期间出现引用变化,那么久导致检查结果不准确了。为了解决这个问题,引入了安全点安全区域的概念

GC算法

算法 大致过程 特点 适用区域
标记-清除算法 标记出需要回收的对象;GC时回收对象内存区域 会产生大量不连续的内存碎片 基础算法
复制算法 将内存分成(一般情况)8:1:1的空间,新生代使用8+1区域,GC时将存活对象移到剩下的1空间内,原区域完全回收 不会产生内存碎片 存活率低的新生代
标记-整理算法 标记、清除后将内存空间整理一下,保证有序 适用于回收频率低的老年代

HotSpot的垃圾收集器

收集器 说明 算法 适用环境
Serial 收集期间停止一切线程,单线程、停顿时间长、专心收集 复制算法 单CPU的Client、新生代
ParNew Serial的多线程版本,能与CMS配合使用 复制算法 Server模式下的首选新生代收集器
Parallel Scavenge 关注点为吞吐率,即提高CPU的使用率 复制 吞吐量优先的新生代收集器
Serial Old Serial的老年代版本 标记-整理 单线程Client老年代收集器
Parallel Old Parallel Scavenge的老年代版本 标记-整理 吞吐量优先的老年代收集器
CMS 以获取最短回收停顿时间为目标、或产生空间碎片,CPU敏感 标记-清除 老年代收集器
G1 将内存分区域,计算每个区域的回收价值,高者优先收集 标记-整理 +复值 面向服务器端的收集器
  • Minor GC:新生代被填满时,GC暂停应用程序回收新生代空间的操作。Eden空间的对象要么被转移、要么被回收,转移的目的地是另一块Survior空间或者老年代
  • Full GC:老年代被填满时,暂停所有应用线程,回收不再使用的对象,整理空间。(CMS和G1的老年代回收算法通过并发等手段减少停顿的时间,尽量达到在应用线程运行的同时进行垃圾回收)

内存分配与回收策略

####1. 对象优先分配在Eden区
如果Eden区无法存放新的对象,那么将触发一次Minor GC,采用复制算法将Eden区的对象转移到Survivor区或者老年代(tenured),看下面的例子:

/**
*
* VM: -XX:+UseSerialGC -Xms40M -Xmx40M - Xmn20M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public class TestInvokeMinorGC {
private static final int _1MB = 1024 * 1024;

public static void main(String[] args) {
byte[] a1 = new byte[1 * _1MB];
byte[] a2 = new byte[12 * _1MB];
byte[] a3 = new byte[4 * _1MB];
}
}

这里的JVM参数限定了固定堆大小为40M,其中新生代20M,Eden与survior的比例为8:1,即eden=16M,survior0=2M,survior1=2M。
首先分配到的a1以及a2对象都能存储在eden区,在分配a3时触发了GC,将a1转移到了s0区,将a2转移到了老年代(分配担保机制)

下面的GC日志中也能看出几个区所占的空间:

####2. 大对象直接进入老年代

大对象指需要大量连续内存空间的对象,典型的是长字符串或数组

可以通过-XX:PretenureSizeThreshold来界定 的下限,该参数只对serial和ParNew两类收集器有效。

####3. 长期存活的对象进入老年代
对象如果进入Survior区那么他的年龄+1,当他的年龄足够大的时候就会进入老年代。看看下面的例子:

/**
*
* VM: -XX:+UseSerialGC -Xms40M -Xmx40M - Xmn20M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:MaxTenuringThreshold=1
*/
public class TestInvokeMinorGC {
private static final int _1MB = 1024 * 1024;

public static void main(String[] args) {
byte[] a1 = new byte[_1MB / 2];
byte[] a2 = new byte[15 * _1MB]; // 第一次GC
byte[] a3 = new byte[2 * _1MB]; // 第二次GC
}
}

JVM参数与上例基本一致,只是定义了进入老年代的年龄下限为1.
在这个例子中,创建a2时触发了一次GC,a1进入S0区,创建a3时再次触发GC:

  • a2由于分配担保机制进入了老年代
  • a1由于年龄=1,也进入了老年代


从GC日志也可以发现只有a3留在了eden区,a1,a2都进入了老年代。

####4. 动态对象年龄判断
survior升迁到老年代并不仅仅依靠年龄判断,当survior的使用空间超过了一半,无论年龄是否达标,都会进入老年代。

承接上面的例子,将a1设置为1MB的对象,并且MaxTenuringThreshold设置为10,结果如下:

在survior中年龄为1的对象也进入了老年代。

第六章 类文件结构

JVM之所以被称为平台、语言无关性的平台,究其原因在于它处理的是字节码(Class类文件),这个字节码是由java或者scala之类的语言编译而来的,因此语言无关;字节码如果解析成0、1在不同的操作系统+体系结构中执行依赖于不同的JVM类型,因此是平台无关的。

第七章 类加载机制

类加载的过程主要包含3步:

  1. 加载:
    根据类的全限定名获取字节流,将其所代表的静态存储结构转化为方法区的运行时数据结构,最后在内存中生成一个代表该类的Class对象
  2. 连接:
    • 验证:对Class文件的字节流进行验证,包含:
      • 文件格式
      • 元数据
      • 字节码
      • 符号引用
    • 准备:为类变量(被static修饰的变量)分配内存并设置初始值
    • 解析
  3. 初始化
    该阶段就是执行类构造器<client>()方法的过程,类构造器有以下特点:
  • 由类变量赋值语句以及static代码块合并而成,如果这些都没有的话就不会产生类构造器
  • <client>()与类的构造函数(也称为实例构造器<init>()方法)不同,不需要显式调用父类构造器。父类的<client>()先执行
  • 多线程环境下初始化一个类时,只会有一个线程执行类的<client>()方法。

第八章 字节码执行引擎

栈帧

栈帧是支持虚拟机进行方法调用和方法执行的数据结构,包含:

  1. 局部变量表:用于存放方法参数以及方法内部的局部变量
    • 该表中的第0号索引指向的是方法所属对象实例的引用,可以通过this来访问这个隐含的参数
    • 局部变量表中的空间是可重用的,如果执行到的位置已经超出某个局部变量的作用域,那么他所在的空间很可能会被其他变量重用。
    • <<effecive Java>>一书中也给出一个编码规则:应该及时清除废弃的引用。
  2. 操作栈
  3. 动态链接
  4. 返回地址

方法调用

  • Class文件的编译过程不包含连接过程,因此Class文件中方法调用的目标方法都是符号引用,只有在运行时才能得到直接引用(存在内存中的方法入口地址)。
  • 可以在不同的阶段获取目标方法的直接引用,包括:类加载的解析阶段以及运行阶段

解析

能够在解析阶段确定方法直接引用具备以下特点:

  • 在编译器可知,运行期不变
  • 静态方法、私有方法以及final修饰的方法在编译期都能确定他们的直接引用,因为这些方法不可能通过继承或别的方式重写。这种类型的方法统称为非虚方法

重载与静态分派

如下定义中:

Fruit apple = new Apple();  //Fruit是Apple的父类

  • Fruit是变量的静态类型,这个是在编译期可知的;
  • Apple是变量的实际类型,这个只有在运行时才可确定;
  • 在重载时,编译器根据传入参数的静态类型决定使用哪个重载版本
  • 依赖静态类型来决定方法的执行版本的分派方式称为静态分派
    • 静态分派发生在编译阶段
    • 在选择重载版本时有一定的优先级,如传入参数类型为char,那么在选择执行版本的时候的优先级为:char > int > long > double > Character > Object > char... 查找父类时按照从下往上的方式

重写与动态分派

public class DynamicDispatch {
static abstract class Human {
protected abstract void say();
}
static class Man extends Human {
@Override
protected void say() {
System.out.println("I'm a Man");
}
}

static class Woman extends Human {
@Override
protected void say() {
System.out.println("I'm an Woman");
}
}

public static void main(String[] args) {
Human woman = new Woman();
Human man = new Man();
man.say(); // I'm a Man
woman.say(); //I'm an Woman
}
}

观察上面main函数对应的字节码:

其中#6对应常量池的引用依然是Human.say

#6 = Methodref          #12.#33        //DynamicDispatch$Human.say:()V

但是执行结果却是定位到Man.say(),这是如何做到的呢?

在执行man.say()时:

  • 获取man变量对应的实际类型C
  • 调用invokevirtual指令,该指令根据实际类型将方法调用的符号引用解析到了确定类型C的直接引用上:
    • 如果在C中能找到对应的方法,并且有权限访问,那么久直接返回这个方法的直接引用
    • 否则,按照继承关系从下往上查找父类来确定方法的直接引用。

以上就是重写的本质,在运行期间根据实际类型确定方法执行版本的分配过程称为动态分派

内存模型与线程

为了与硬件中的主内存、高速缓存、处理器的交互关系一致,java内存模型也定义了:主内存工作内存以及线程

  • 所有的变量都存储在主内存中(包括实例字段、静态字段和构成数组对象的元素,不包括局部变量以及方法参数。后者存放在线程的私有栈中不存在共享的问题)
  • 每个线程都有自己的工作内存,工作内存中存放着主内存中变量的副本拷贝
  • 线程对变量的读写操作只能在工作内存中进行,换而言之,线程操作只是主内存中变量的一个影分身

为了实现主内存与工作内存之间的交互,Java内存模型定义了8种原子性的操作,其中:

  • 从内存中复制变量到工作内存需要执行:read -> load
  • 将工作内存中的变量同步回主内存:store -> write
    以上两步只是顺序执行,中间可允许穿插其他指令,这就是共享变量在多线程运行下可能出现结果不确定性的根源。

volatile变量与可见性

可见性:一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

volatile是一个特殊变量类型,它通过以下的规则提供了弱同步机制

  • 在使用一个变量之前,必须执行load操作。确保使用变量时能看到主内存中最新的值
  • 对变量执行了赋值操作后,必须连续执行store以及write操作。确保每次修改了变量的值立刻同步回主内存

volatile通过以上的机制保证了变量的可见性,其通常用作某个操作完成、发生中断或者状态的标记,下面是典型的例子:

volatile boolean shouldShutDown;

public void shutDown() {
shouldShutDown = true;
}

while (shouldShutDown) {
...
}

在多线程情况下,任意的线程调用shutDown方法都能正常关闭。这是因为对shutDown的赋值不依赖于当前值,而volatile的可见性确保该变量变为true时其他线程都能得知变化。

之所以说volatile实现的是弱同步,在于其未实现原子性,多线程读写volatile变量的结果是未知的。

原子性与CAS

synchronized代码块之间的操作是具有原子性的,除此以外,java.util.concurrent.atomic包提供了诸如AtomicInteger之类具有原子性操作的类,这些类的原子性并不是通过加锁实现的,而是通过CAS(Compare And Swap)实现的,在JDK 1.5之前,AtomicInteger的incrementAndGet()方法的实现是:

while(true) {
next = current + 1;
if (compareAndSet(current, next)) {
return next;
}
}

假如有两个线程同时调用了同一个AtomicInteger对象的incrementAndGet方法,那么无论如何结果都是++2。