《深入理解JVM》部分要点总结

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

一、运行时数据区域

1.方法区(Non-Heap/永久代)

方法区是个线程共享的内存区域,主要存储:已被JVM加载的类信息,常量,静态变量,即使编译器编译后的代码。
不需要连续的内存,可以选择固定大小或可扩展的大小,此外还可以选择不实现GC。
该区域的GC目标主要是对常量池的回收和对类型的卸载。
当方法区无法满足内存分配需求时,会抛出OutOfMemoryError异常。

2.虚拟机栈

虚拟机栈是线程私有的,主要执行Java方法,描述了Java方法执行的内存模型。
每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等。一个方法从调用至执行完成的过程,就对应着一个栈帧在VM Stack中从入栈到出栈的过程。

局部变量表:存放了编译器可知的基本数据类型、对象引用、returnAddress。该表所需的内存空间在编译器就会完成分配,运行期间不会改变大小。

当线程请求的栈深度超出了JVM允许的深度,会抛出StackOverflowError异常,当扩展时无法申请到足够的内存时,会抛出OutOfMemoryError异常。

3.本地方法栈

本地方法栈也是线程私有的,主要执行虚拟机用的Native方法。
在HotSpot中,本地方法栈与虚拟机栈合二为一。

4.堆

堆是所有线程共享的,是JVM所管理的内存中最大的一块,几乎所有的数组/对象实例都在这里分配内存。

Java堆可以细分为新生代和老年代,再细分可以分为Eden、From Survivor、To Survivor空间。

Java堆在物理上可以是不连续的,但在逻辑上是连续的,当内存不足以完成实例分配且无法扩展时,会抛出OutOfMemoryError异常。

5.程序计数器

程序计数器是线程私有的,是一块较小的内存空间,可看作是当前线程所执行的字节码的行号指示器。每个线程都有一个独立的程序计数器,各计数器互不影响。

  1. 执行Java方法:计数器记录正在执行的虚拟机字节码指令地址
  2. 执行Native方法:计数器值为空

6.直接内存

NIO提供了一种基于通道和缓冲区的I/O方式,可以使用Native函数库直接分配堆外内存,然后通过一个存在堆中的DirectByteBuffer对象作为这块内存的引用。
这种方法可以避免在Java堆和Native堆中来回复制从而提高性能。

二、创建对象

在遇到New指令后执行如下步骤:

1.检查

检查能否在常量池定位类的符号引用,并检查这个类是否被加载解析、初始化,如果没有,则先加载类,加载完成后执行第二步。

2.分配内存

对象所需的内存大小在类加载完成时即确定,内存分配主要有两种方式:

  1. 指针碰撞:Java堆中的内存绝对规整,用过的在一边,空闲的在另一边,中间有一个作为分界点指示器的指针,分配内存只需将该指针移动一段距离。
  2. 空闲列表:堆中的内存不规整,已用内存与空闲内存互相交错。此时VM维护一个列表,记录哪些内存块可用,并找一块足够大的用于分配。

选择哪种方式来进行内存分配取决于堆是否规整,而堆是否规整取决于GC是否带有压缩整理功能。

内存分配在并发的情况下不是线程安全的,有两种解决方式:

  1. 内存分配操作同步处理(慢分配)
    将所有内存分配操作全部同步处理,VM采用CAS(Compare And Swap)+失败重试来保证更新操作的原子性。
  2. 本地线程分配缓冲(快分配/TLAB)
    为每个线程在Java堆中预先分配一小块内存,在需要分配时每个线程在自己的TLAB上进行分配,只有在TLAB用完并需要分配新TLAB时才需要同步锁定。

3.内存空间初始化

将分配到的内存空间都初始化为二进制的零值,保证对象不赋初值也可以直接使用。

4.对对象进行必要设置

主要包括:该对象是哪个类的实例,如何找到类的元数据,对象的Hash值,对象的GC
代。这些信息保存在对象头中。

三、对象的内存布局

1.内存头

包括:

  1. 用于存储对象自身运行时的数据(HashCode/GC代年龄/锁标志),数据结构不固定。
  2. 类型指针,指向类元数据,用于确定该对象属于哪个类。
  3. 若对象为数组,对象头还记录数据长度。

2.示例数据

存储数据中定义的各类字段,分配策略:long/double->int->short/char->byte/boolean->OrdinaryObjectPointer
相同大小的字段总是被分配到一起,在满足这个条件的前提下,父类中定义的变量会出现在子类之前。

3.对齐填充(非必须)

HotSpot VM要求对象起始地址是8字节的整数倍,大小也是8字节的整数倍。如果对象实例数据没有对齐,则需要对齐填充补全。

四、对象访问定位

对象的访问定位,即如何从Reference找到具体对象,有两种方式:

  1. 句柄:堆中划分一块内存作为句柄池,Reference存的是对象的句柄地址,句柄中保存对象实例数据与类型数据各自的具体信息。
  2. 直接指针:Reference中保存的是对象实例数据的地址,实例数据中存仓指向类型数据的指针。

优劣比较:

  1. 句柄方式在对象呗移动时只需改变句柄保存的内容,无需改变Reference,比较稳定。
  2. 直接指针节省了一次指针定位的开销,速度较快(HotSpot采用该方式实现)

第三章  垃圾收集器与内存分配策略

一、对象已死吗

1.对象存活状态判定

对于对象的存活状态,主要有两种方法进行判断:

  1. 引用计数:给对象添加引用计数器,有引用+1,引用失效-1,为0则不可再用,但这种方法不能解决循环引用的问题。
  2. 可达性分析(Java采用这种方法):从GC Roots为起点出发,向下搜索,所走的路径叫做引用链,被引用链连起来的是可用的,不可达的是不可用的。

2.对象的引用强度

  1. 强引用:代码中普遍存在,只要强引用还在,对象就不会被回收
  2. 软引用:还有用但是非必需的对象,在内存溢出之前会被回收
  3. 弱引用:该类对象只能生存到下一次GC前,在GC时会被回收
  4. 虚引用:该引用不会影响对象的生存时间,也不能用来获得对象实例。虚引用的意义是在对象被GC时收到通知。

2.Finalize

对于不可达的对象,会进行两次标记。

  1. 对象没有覆盖finalize或已经吊用过finalze–>回收
  2. 将对象移入F-Queue,执行Finalize方法(但是不保证会等待方法结束),在finalize中仍然没有建立对对象的引用–>回收
  3. finalize中建立了对对象的引用,移出GC列表

3.回收永久代

永久代主要回收:无引用的废弃常量,要被卸载的无用类

无用类的判断规则(需同时满足):

  1. 该类所有实例都已回收
  2. 加载该类的ClassLoader已被回收
  3. 该类对应的java.lang.class对象没有任何地方被引用,无法通过反射访问类

意义:大量使用反射,动态代理,CGLib等频繁自定义ClassLoader的情况下防止永久代溢出。

二、垃圾收集算法

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

先用判定方法标记对象的存活状态,然后进行回收。
缺点:

  1. 效率低
  2. 回收后空间碎片多

2.复制(Copying)

将内存划分为大小相等的两块,每次仅用一块,当需要GC时,将其中存活的对象复制到另一块,回收前一块。

优点:只需移动堆顶指针,按顺序分配内存,实现简单,效率高。
缺点:可用内存减半,代价较高。
改进:将内存分为一个较大的Eden和两个较小的Survivor(HotSpot中比例为8:1:1),使用时占用一个Eden和一个Survivor,GC时复制到另一个Survivor,当空间不足时,要进行分配担保。

3.标记-整理(Mark-Compact)

先标记,然后将存活的对象向一端移动,在清理边界以外的内存。

4.分代收集

对于新生代,采用“复制”的方法。
对于老年代,采用“标记-清理”或者“标记-整理”的方法。

三、G1收集器

G1收集器是HotSpot采用的收集器

1.特点

  1. 并行与并发:并行处理,缩短Stop-The-World的时间。并发处理,在查找Root的时候Java程序可以继续执行。
  2. 分代收集
  3. 空间整合:整体上看是基于“标记-整理”,局部上看是基于“复制”,不产生空间碎片。
  4. 可预测的停顿:可指定在M毫秒的时间段内,可用于GC的时间不得超过N毫秒。

2.内存管理

将内存堆划分为多个大小相等的Region,追踪各个Region里垃圾价值大小。维护一个优先列表,每次根据允许的时间回收价值最大的Region。

3.G1工作步骤

  1. 初始标记:标记GC Roots能直接关联的对象。这步需要停顿线程,但耗时短。
  2. 并发标记:进行可达性分析,寻找存活的对象。
  3. 最终标记:修正并发标记时由于并发操作而产生变化的部分,需要停顿线程,但可以并发执行。
  4. 筛选回收:对各Region回收价值与成本进行排序,根据期望时间指定回收计划。

四、内存分配与回收策略

1.对象优先在Eden分配

大多数情况下,对象在新生代Eden区中进行分配,当Eden区没有足够的空间时,将会出发一次Minor GC。

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

对于需要大量连续内存空间的Java对象,直接在老年代中分配内存。

3.长期存活的对象进入老年代

对象每活过一次GC,年龄就会+1,当年龄增加到一定程度(可在VM参数中设置)后,就会进入老年代。

4.动态对象年龄判定

如果Survivor中相同年龄的所有对象大小的综合大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

5.空间分配担保

JDK 6以后,在老年代连续空间大于新生代对象总大小或者历次晋升平均大小时进行MinorGC,否则进行FullGC


第五章  VM调优

一、高性能硬件部署策略

1.通过64位JDK使用大内存

缺点:

  1. 内存回收导致长时间停顿
  2. 64位JDK性能较低
  3. 需要保证程序足够稳定,因为如果溢出无法转储,也不能分析
  4. 64位内存消耗比32位的大

2.用若干32位虚拟机建立逻辑集群

缺点:

  1. 需要避免竞争全局资源
  2. 很难高效利用某些资源池
  3. 受32位限制,Windows堆只能开1.5GB,Linux只能开4GB
  4. 每个逻辑节点上都有缓存,造成内存浪费。

3.JVM运行中可能存在的若干问题

  1. 集群间频繁同步导致内存溢出
  2. 堆外内存溢出
  3. 调用外部命令导致系统缓慢
  4. JVM崩溃
  5. 不恰当的数据结构导致内存占用过大
  6. 由于Windows虚拟内存导致交换到页面文件中从而导致长时间停顿

二、常用优化方法

  1. 升级VM版本
  2. -Xverify:none 禁用字节码验证
  3. 调整内存设置,设定恰当的新生代容量,减少GC频率
  4. 指定GC收集器

第六章  类文件结构

一、Class类文件结构

1.前4字节

魔数,用于确定这个文件是否是一个能够被VM接受的class文件,class文件的前四字节为0xCAFEBABE

2.5-8字节

第5和第6字节为版本号,第7和第8字节为主版本号。
高版本JDK只能运行以前的class文件,不能运行更高版本的class文件。

3.常量池

主要存放两大类常量:字面量,符号引用(类和接口的全限定名/字段的名称和描述符/方法的名称和描述符)。
入口有一个u2类型的变量,表示池中常量的数量,随后根据常量类型标志+常量内容位来表示常量。

4.访问标志

用两字节表示访问标志,用于标识一些类或接口层次的访问信息。

5.类索引,父类索引与接口索引集合

6.字段表集合

用于描述接口或勒种生命的变量,包括类级变量和实例级变量,但不包括方法内部的局部变量。
其中两字节用来描述字段访问性,两字节用来表示名称(常量池索引),两字节用来表示描述符(常量池索引)。

7.方法表集合

8.属性表集合

部分属性:

  1. Code:以字节码形式存在的Java方法体代码
  2. Exceptions:列举方法中throw出的异常类型
  3. LineNumberTable:描述源码行号与字节码行号之间的对应关系
  4. LocalVariableTable:描述栈帧中局部变量表中的变量与源码中定义的变量之间的关系
  5. SourceFile:记录生成这个Class文件的源文件名
  6. ConstantValue:通知VM为静态变量赋值
  7. InnerClass:记录内部类与宿主类间的关联
  8. Signature:泛型类型,使反射API可以获取泛型类型。

二、字节码

1.加载与存储

加载:load
存储:store
常量加载到操作数栈:push
扩充局部变量表的访问索引:wide

2.运算

加减乘除:add/sub/mul/div
取模:rem
位移:shl/shr
取反:neg
按位或:or
按位与:and
按位异或:xor
自增:inc
比较:cmpg,cmpl

3.类型转换

i2b/i2c/i2s/l2i/f2i……

4.对象

对象创建:new 
数组创建:newarray、anewarray、multianewarray
访问类实例:getfield、putfield、getstatic、putstatic
数组压栈:aload
将栈值存到数组:astore
取数组长度:arraylength
检查实例类型:instanceof、checkcast

5.操作数栈管理

出栈:pop、pop2(2元素)
复制栈顶并压栈:dup、dup2
交换栈顶俩元素:swap

6.控制转移

7.方法调用与返回

调用方法:invokevirtual
调用接口方法:invokeinterface
调用特殊方法:invokespecial(初始化、私有方法、父类方法)
调用静态方法:invokestatic
调用在运行时动态解析出调用点限定符的方法:invokedynamic

8.异常处理:athrow

9.同步

JVM使用monitorenter和monitorexit来支持同步的加锁与释放。

第七章  虚拟机类加载机制

一、类加载顺序

1.生命周期

加载->验证->准备->解析->初始化->使用->卸载

2.加载时机

必须进行初始化的情形(加载,验证,准备自然需要在此之前开始):

  1. 遇到new(实例化对象),getstatic(读取静态字段),putstatic(设置静态字段)或者invokestatic(调用静态方法)这4条字节码指令时,如果类没有初始化则必须初始化。
  2. 使用反射
  3. 初始化类时,其父类还未初始化
  4. VM启动时执行的主类
  5. 使用JDK1.7动态语言支持时,如果一个java.lang.invoke.MethodHandler实例最后的解析结果是REF_getStatic,REF_putStatic,REF_invokeStatck方法家句柄,并且这个方法句柄对应的类没有初始化,则需要先触发其初始化

接口在初始化时,并不要求其父类接口全部完成初始化,只有在使用父接口时才初始化。

3.加载

加载阶段的主要任务:

  1. 通过一个类的全限定名获取此类的一个二进制字节流。
  2. 通过字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表该类的java.lang.class对象,作为方法区的这个类各种数据访问入口。

非数组类的家在阶段既可以由引导类加载器完成,也可以由自定义类加载器去完成(重写类加载器的loadClass)。

4.验证

确保Class文件的字节流中包含的信息符合当前VM的要求,需要验证如下内容:

  1. 文件格式验证
  2. 元数据验证(是否有父类,是否继承了final类,是否实现了要求的方法)
  3. 字节码验证
  4. 符号引用验证

5.准备

为类变量分配内存并设置初始值,内存分配仅包括类变量(被static修饰的变量),初始值也只是赋0值,但被final修饰的变量会赋具体值。

6.解析

将常量池中的符号引用转换为直接引用,主要完成如下解析:

  1. 类或接口的解析
  2. 字段解析
  3. 类方法解析
  4. 接口方法解析

7.初始化

<clinit>由编译器自动收集类中所有类变量赋值动作和静态语句块合并产生。VM中第一个被执行的<clinit>()方法一定是java.lang.object
如果各线程同时初始化一个类,只有一个线程会执行<clinit>()方法,其他线程都会阻塞。

二、类加载器

1.类相等判断

类由加载它的类加载器和这个类本身一同确立起在JVM中的唯一性,必须来源于同一个加载器的同样的类在相等。

2.双亲委派模型

启动类加载器:使用C++实现(HotSpot),主要加载Java_Home/lib,无法手动引用,如需委派,需要在ClassLoader中返回null。
扩展类加载器:主要加载Java_Home/lib/ext
应用程序加载器:系统类加载器,也是默认加载器。

所有请求都会向上传递至启动类加载器,当无法完成加载请求时才会向下传递,再由子加载器进行加载。

第八章  虚拟机字节码执行引擎

一、栈帧

栈帧是VM栈中的栈元素,是一种数据结构,存储了方法的局部变量表,操作数栈等信息,具体结构如下图所示。

1.局部变量表

用于存放方法内的局部变量,最小单位为slot,slot的具体大小是可变的。
slot是可以服用的,这回影响GC的行为,一个变量可能已经无用了,但局部变量表中还保存着该变量的引用,这个变量就不会被回收。当手动设置为null并被其他新变量覆盖时才会GC。

2.操作数栈(后入先出)

初始为空,在方法执行时由字节码指令入/出栈。

3.动态连接

指向运行时常量池中该栈帧所属方法的引用。

4.方法返回地址

保存方法被调用的位置。

二、方法调用

1.解析

在解析阶段,会将一部分符号引用转换为直接引用。
该部分替换的都是非虚方法,主要包括静态方法、私有方法、实例构造器、父类方法、Final方法。

2.分派

  1. 静态分派:根据参数的静态类型来定位方法的执行版本(重载Overload)。
  2. 动态分派:根据实际动态类型来选择方法的执行版本(重写Override)。

第十二章  Java内存模型与线程

一、Java内存模型

1.内存模型目标

Java内存模型的主要目标是定义程序中各个变量(包括实例字段、静态字段、构成数组对象的元素,但不包括局部变量与方法参数,因为是线程私有不共享的)的访问规则,即在VM中将变量存储到内存和从内存中取出变量这样的底层细节。
其意义是可以屏蔽掉各种硬件和OS的内存访问差异。

2.工作模式

所有变量都存储在主内存中,每条线程都有自己的工作内存,工作内存中保存了主内存的拷贝。
线程对变量的所有操作都必须在工作内存中进行。

3.交互操作

  1. lock:作用与主内存,把变量标记为一条线程独占的状态
  2. unlock:作用与主内存,释放一个变量的锁定
  3. read:从主内存中读取
  4. load:写入工作内存(与read同时出现)
  5. use:将工作内存变量传递给执行引擎
  6. assign:从执行引擎取值赋给工作内存
  7. store:从工作内存中读取
  8. write:写入主内存(与store同时出现)

二、Java与线程

1.线程实现方式

  1. 内核线程实现(调用接口:轻量级线程)
  2. 用户线程实现
  3. 用户线程+轻量级线程混合实现

2.线程安全的实现方式

  1. 互斥同步(加锁)
  2. 非阻塞同步(基于冲突检测的乐观并发策略)
  3. 无同步(可重入代码/线程本地存储)
文章作者: Lufer
文章链接: https://coder.lufer.cc/2019/08/08/《深入理解JVM》部分要点总结/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Lufer
支付宝打赏
微信打赏