《深入理解Java虚拟机》读书笔记

作为一名Java开发人员,不能局限于Java语言规范,更需要对Java虚拟机规范有所了解。Java虚拟机规范有多种实现,其中HotSpot VM是Oracle JDK和Open JDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。

:本文大部分摘自《深入理解Java虚拟机(第二版)》

1 内存管理机制

Java虚拟机内存模型包括程序计数器、虚拟机栈、本地方法栈、方法区、堆,如图所示

Java虚拟机运行时内存模型

1.1 程序计数器

程序计数器是一块较小的内存空间,可以看作当前线程所执行的字节码行号指示器。需要注意以下几点内容:

  • 程序计数器是线程私有,各线程之间互不影响
  • 如果正在执行java方法,计数器记录的是正在执行的虚拟机字节码指令地址
  • 如果执行native方法,这个计数器为null
  • 程序计数器也是在Java虚拟机规范中唯一没有规定任何OutOfMemoryError异常情况的区域

1.2 虚拟机栈

虚拟机栈即我们平时经常说的栈内存,也是线程私有,是Java方法执行时的内存模型,每个方法在执行时都会创建一个栈帧用于储存以下内容:

  • 局部变量表:32位变量槽,存放了编译期可知的各种基本数据类型、对象引用、returnAddress类型。
  • 操作数栈:基于栈的执行引擎,虚拟机把操作数栈作为它的工作区,大多数指令都要从这里弹出数据、执行运算,然后把结果压回操作数栈。
  • 动态连接:每个栈帧都包含一个指向运行时常量池(方法区的一部分)中该栈帧所属方法的引用。持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另一部分将在每一次的运行期间转化为直接应用,这部分称为动态连接。
  • 方法出口:返回方法被调用的位置,恢复上层方法的局部变量和操作数栈,如果无返回值,则把它压入调用者的操作数栈。

1.3 本地方法栈

本地方法栈是线程私有,与虚拟机栈类似,为native方法服务。

1.4 方法区

线程共享,用于储存已被虚拟机加载的类信息、常量、静态变量,即编译器编译后的代码,方法区也称持久代(Permanent Generation),主要存放java类定义信息,与垃圾回收关系不大,但不是没有垃圾回收,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。运行时常量池,方法区的一部分,虚拟机加载Class后把常量池中的数据放入运行时常量池。

1.5 堆

堆是JVM中最大的一块区域,线程共享,此区唯一的目的就是存放对象实例,几乎所有对象实例都在这里分配,但是随着JIT编译器及逃逸分析技术的发展,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有对象都分配在堆上也渐渐变的不是那么绝对。

  • 新生代:包括Eden区、From Survivor区、To Survivor区,系统默认大小Eden:Survivor=8:1
  • 老年代:在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

2 垃圾回收机制

说起GC,大部分人都会把这项技术当作Java语言的产物,其实GC的历史比Java久远。GC中不外乎两个步骤:1.确定哪些是垃圾,2.进行垃圾回收

2.1 对象已死的判定

如何确定一个对象是否“死亡”?目前有两种方式:

  • 引用计数算法,给对象添加一个计数器,每当有一个地方引用它时,计数器加1;当引用失效,计数器减1;计数器为0的对象就是不可能再被使用的。目前在微软的COM技术、Python语言都广泛使用该算法进行内存管理,但是至少主流的Java虚拟机没有选择该算法来管理内存对象,其中最主要原因是它无法解决对象之间的相互循环引用问题。
  • 可达性分析算法,基本思想就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链时,则证明此对象是不可用的。如图所示
可达性分析算法

2.2 垃圾回收算法

  • 标记-清除算法,首先标记出所有需要回收的对象,然后进行统一的回收,不足之处有两个:效率低、碎片多。
  • 复制算法,将可用内存划分成大小相等的两块,每次只使用一块,当一块用完了,就将还存活的对象复制到另外一块上,然后把已使用的内存空间清理掉。不足之处是将内存缩小到一半,利用率不高。
  • 标记-整理算法,与标记-清除类似,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的区域
  • 分代收集算法,分代收集是目前jvm普遍采用的算法,即新生代采用复制算法,因为有大量新生对象死去,只有少量存活;老年代采用标记-整理,因为老年代中对象存活率高,没有额外的空间对它进行担保。

2.3 垃圾回收器

如果说垃圾回收算法是内存回收的方法论,那么垃圾收集器就是内存回收的实现。Java虚拟机规范中并没有对垃圾收集器应该如何实现作相应规定,因此不同厂商、不同版本差异很大。在HotSpot的发展过程中,有七种垃圾回收器,如图所示。在JDK1.7以后开始采用G1,本文重点分析G1垃圾回收器,对于其他垃圾回收器,读者可自行学习。

垃圾回收器

G1收集器将整个Java堆划分为多个大小相等的独立域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region的集合。G1跟踪各个Region中垃圾堆积的价值大小(回收所获得的空间大小及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。Region之间的对象引用以及其他垃圾回收器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的,G1中每个Region中都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行读写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同Region中,如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中。

3 类加载机制

3.1 Class文件结构

或许大部分程序员都还认为Java虚拟机执行Java程序是一件理所当然的和天经地义的事情,但是在Java发展之初,设计者就曾考虑过并实现了让其他语言运行在Java虚拟机之上的可能,他们在发布规范文档的时候,也刻意把Java的规范拆分成了Java语言规范Java虚拟机规范。时至今日,商业和开源机构已经在Java语言之外发展出一大批在Java虚拟机上运行的语言,如Groovy、JRuby、Scala等。</br>
实现语言无关性的基础仍然是虚拟机和字节码存储格式,Java虚拟机不和任何语言绑定,它只与Class文件的二进制文件格式相关联,理论上讲,任一门功能性语言都可以表示为一个能被Java虚拟机所接受的有效的Class文件。</br>

Java虚拟机与Class文件

Class文件结构包括以下内容:

  • 魔数:确定这个文件能否被Java虚拟机接受,值为0xCAFFBABE(咖啡宝贝?)
  • 版本号:Class文件的版本号
  • 常量池:Class文件的资源仓库
  • 访问标志:用于识别一些类或者接口层次的访问信息,是类还是接口?是否为public?
  • 索引集合:包括类索引、父类索引、接口索引
  • 字段表集合:描述接口或类中声明的变量,但不包括方法内部的局部变量
  • 方法表集合:代码在方法表中的属性集合“Code”属性
  • 属性表集合:字段表、方法表都可以携带自己的属性表,以用于描述某些场景专用信息

3.2 类加载过程

  • 加载
    加载阶段虚拟机需要完成以下3件事:
    (1) 通过一个类的全限定名来获取此类的二进制字节流
    (2) 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
    (3) 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
    虚拟机这三点要求并不算具体,因此虚拟机实现与具体应用的灵活度都是相当大的。虚拟机设计团队在加载阶段搭建了一个相当开放的、广阔的舞台,Java发展历程中,充满创造力的开发人员玩出了各种花样,例如:

  • 从ZIP包中读取,最终成为日后的JAR、WAR格式基础

  • 从网络中获取,典型应用就是Applet

  • 运行时计算生成,这种场景使用最多的是动态代理技术

  • 由其他文件生成,典型场景是JSP应用

  • 从数据库中读取,使用较少

  • 验证
    目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

  • 准备
    正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区进行分配,注意是类变量(static修饰),不是实例变量。

  • 解析
    虚拟机将常量池内的符号引用替换为直接引用的过程,包括类或接口的解析、字段解析、类方法解析、接口方法解析。

  • 初始化
    初始化类和其他资源

3.3 类加载器

类加载器用于实现类的加载动作,对于任意一个类,都需要由加载它的类加载器和这个类本省一同确立其在Java虚拟机中的唯一性,每个类加载器都有一个独立的类名称空间,比较两个类是否相等,只有在这两个类是同一个类加载器加载的前提下才有意义。,例如Class对象的equals()、isInstance()。
从Java开发人员的角度看,类加载器可划分为3种:

  • 启动类加载器,负责加载存放在<JAVA_HOME>\lib下面的类库
  • 扩展类加载器,负责加载存放在<JAVA_HOME>\lib\ext下面的类库
  • 应用程序类加载器,负责加载用户路径上的类库
双亲委派模型

双亲委派模型的工作过程是:如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去加载,每一层次的类加载器都是如此,因此所有的加载请求最终都应传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。双亲委派模型并不是强制性约束,双亲委派模型出现过3次较大规模的“被破坏”情况,其中JNDI、Dubbo使用SPI技术就是对双亲委派模型的破坏,此外还有Java1.9中最大的特性OSGi也是对双亲委派的破坏,有兴趣的朋友可以扩展阅读。

4 性能监控调优

jvm启动参数

参数名称 说明
-Xms 初始堆大小,物理内存的1/64(<1GB)
-Xmx 最大堆大小,物理内存的1/4(<1GB)
-Xmn 年轻代大小,此处的大小是(eden+ 2 survivor space)
-XX:PermSize 设置持久代初始值,物理内存的1/64
-XX:MaxPermSize 设置持久代最大值 物理内存的1/4
-Xss 每个线程的堆栈大小,JDK1.5以后为1M
-XX:NewRatio 年轻代(包括Eden和两个Survivor区)与年老代的比值

性能优化四个命令:

  • jps:查看java进程
  • jstat:显示本地或者远程虚拟机垃圾回收,例如:jstat -gcutil $pid 1000 5
  • jmap:查看JVM堆中对象详细占用情况,例如:jmap -histo [pid]
  • jstack:用于生成虚拟机当前线程快照,jstack -l [pid]

注:文中图片均来自网络

推荐阅读更多精彩内容