PancrasL的博客

搞懂 Java 虚拟机

2021-04-16

img

1. Java自动内存管理

1.1 Java虚拟机数据区

image-20210416193406740
  • 程序计数器

一块较小的内存空间,用于记录当前线程所执行的字节码的行号指示器。

  • Java虚拟机栈

Java虚拟机栈是线程私有的,生命周期与线程相同。每一个方法被执行时,Java虚拟机都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

  • 本地方法栈

为虚拟机使用到的本地方法服务。在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

  • Java堆

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,唯一目的是用于存放对象实例。

Java堆同时是垃圾收集器管理的内存区域,因此又称“GC堆”(Garbage Collected Heap)。

  • 方法区

存放已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

  • 运行时

存放编译期生成的各种字面量与符号引用。

  • 直接内存

基于通道(Channel)与缓冲区 (Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作。

1.2 HotSpot虚拟机创建、访问对象的过程

  • 对象的创建

(1)当Java虚拟机遇到一条字节码new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载、解析和初始化过。如果没有,需要先执行相应的类加载过程

(2)类加载检查通过后,虚拟机将为新生对象分配内存。

内存分配的方法:①指针碰撞:空闲空间和使用空间位于分界线两侧,把指向空闲空间的指针向下移动一个对象的大小 ②:空闲列表:由虚拟机维护一张列表,用于记录哪些内存块可用,在分配的时候从列表中找到一块足够大的空间分配给对象实例。

内存分配时的同步问题:①对分配动作进行同步处理(加锁) ②本地线程分配缓冲:把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,当本地缓冲用完时,分配新的缓冲区才需要同步锁定,减少了同步的开销。

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 和失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

(3)内存分配完毕后,虚拟机会将内存区域置零。

(4)接下来,虚拟机会对对象进行设置,包括标识对象属于哪个类、类的元数据信息的位置、对象的GC分代年龄、哈希码,这些信息会被记录到对象头中。

(5)new指令执行完毕后会接着执行()方法,对对象进行初始化。

1.3 对象的内存布局

(1)对象头(Header):用于对象自身的运行时数据:HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

(2)类型指针:指向所属类的元数据的指针。

(3)实例(Instance Data):对象存储的数据。

(4)对齐填充(Padding):起占位符作用。

1.4 对象位置的定位

1.4.1 通过句柄访问对象

优点:reference中存储的是句柄地址,在对象被移动时(例如整理内存碎片)无需改变reference本身

缺点:两次寻址开销

image-20210416203400581

1.4.2 通过直接指针访问对象

优点:访问速度快,节省了一次寻址开销(HotSpot采用的方式)

image-20210416203836898

1.5 Java内存溢出

1.5.1 堆溢出

1
2
3
4
5
6
7
8
9
10
Exception in thread "main" java.lang.OutOfMemoryError: Java heap spacepublic class HeapOverflow {
public static void main(String[] args) {
ArrayList<Integer> list = new ArrayList<>();
while (true)
list.add(1);
}
}
/*
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
*/

1.5.2 栈溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
public class StackOverflow {
public static void dfs(int i){
dfs(i + 1);
}

public static void main(String[] args) {
dfs(0);
}
}

/*
Exception in thread "main" java.lang.StackOverflowError
*/

1.5.3 方法区和运行时常量池溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class RuntimeConstantPoolOverflow {
public static void main(String[] args) {
Set<String> set = new HashSet<>();
int i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}

/*
jdk6报Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
jdk7及以上报Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
原因:自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中
*/

1.5.4 本机直接内存溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DirectMemoryOverflow {
public static void main(String[] args) throws Exception {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(Integer.MAX_VALUE);
}
}
}

/*
Exception in thread "main" java.lang.OutOfMemoryError
*/

1.6 Java类加载器

1.6.1 双亲委派机制

上层类加载器呼叫底层类加载器加载,如果底层不能加载,再有上层类加载。

  • 优点:
    • 避免类的重复加载
    • 防止核心API被恶意篡改

1.6.2 沙箱安全机制

2. 垃圾收集器

2.1 对象的生命周期

2.1.1 垃圾回收的对象

  • 方法结束时该方法所涉及的PC、栈帧

  • 堆中已经“死掉”的对象(垃圾收集)

2.1.2 垃圾回收的时机

  • 显式调用 System.gc()

  • 每隔一段时间Java虚拟机会自动调用 gc()

2.1.3 对象的引用类型

  • 强引用:形如 Object obj = new Object()obj1.friends = obj2 的形式,垃圾收集器永远不会回收存在强引用的对象。
  • 软引用:一些还有用、但非必须的对象,在内存发生溢出前被回收。使用 SoftReference 类来实现软引用。
  • 弱引用:非必须对象,强度弱于软引用,在下一次垃圾收集时回收。使用 WeakReference 类来实现弱引用。
  • 虚引用:最弱的一种引用关系,只是为了能在对象被回收时收到一个系统通知。使用 PhantomReference 来实现虚引用。

2.1.4 对象回收的触发条件

  • 第一次标记(回收):可达性分析后与GC Roots不相连的对象。
  • 第二次标记(避免被回收):筛选出有必要执行finalize()方法的对象(即finalize被用户覆盖的对象),将其加入到F-Queue,如果对象在finalize()中重新建立了关联,该对象会拯救,不会被回收。

2.1.5 回收方法区的考量

  • 回收方法区的收益低

垃圾收集通常可以回收70%-90%的空间,相比之下,方法区回收囿于苛刻的判定条件,回收效果远低于此。

  • 方法区的垃圾收集的内容
    • 废弃的常量
      • 回收行为和Java堆很类似。
      • 例如:一个字符串 “java” 曾进入常量池,但程序中没有任何字符串对象引用“Java”常量,且虚拟机也没有其他地方引用这个字面量,此时如果发生内存回收,且垃圾收集器判断有必要时,该常量将被清理出常量池。常量池中的其他类、方法、字段的符号同理。
    • 不再使用的类型
      • 判定条件苛刻
        • 该类的所有实例被回收
        • 类加载器被回收
        • 类的 java.lang.Class 对象没有在任何地方被应用,无法通过反射创建该类的方法
      • 通过参数 -Xnoclassgc 参数进行控制

2.2 引用计数法和可达性分析法

2.2.1 引用计数法

使用额外的内存空间进行引用计数,适合大多数场景,但是存在例外情况(例如循环引用),需要大量的额外处理才能解决内存泄漏。

1
2
3
4
5
6
7
8
9
10
User u1 = new User();
User u2 = new User();
u1.friends = u2;
u2.friends = u1;

u1 = null;
u2 = null;

// 无法回收u1、u2所占用的空间
System.gc();

2.2.2 可达性分析算法

Java、C#、Lisp采用的分析方法。从”GC Roots“开始,根据引用关系向下搜索,标记不可达的对象为待回收。

2.3 垃圾收集算法

  • 计数式垃圾收集(Reference Counting GC)(略)
  • 追踪式垃圾收集(Tracing GC)(Java)

2.3.1 分代收集理论

  • 分代假说:绝大多数对象都是朝生夕灭的。

  • 分代解说:熬过越多次垃圾收集过程的对象就越难以消亡。

  • 跨代引用假说:跨代引用相较于同代引用来说仅占极少数。


垃圾收集器的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同区域之中存储。

  • 新生代:存活时间短的对象
  • 老生代:存活时间长的对象

2.3.2 标记-清除算法

  • 标记

标记出所有需要回收的对象

  • 清除

标记完成过后统一回收所有被标记的对象

  • 缺点

① 执行效率不稳定,尤其是当Java堆中存在大量需要被回收的对象时。

② 内存碎片化。

image-20210418184448479

2.3.3 标记-复制算法

  • 半区复制

将一块内存分为两块,当一块的内存用完后,将还存活的对象复制到另一块,然后将已使用过的内存空间一次清理掉。

  • 缺点

浪费空间,可用空间只有原空间的1/2。

  • 应用

现在的商用Java虚拟机采用该算法回收新生代,但由于新生代的大多数对象的存活时间很短,因此可以修改内存分配比例,例如HotSpot的将划分比例设置为为8:1(Eden:Survivor)。

image-20210418184801176

2.3.4 标记-整理算法

  • 标记

标记出所有需要回收的对象

  • 整理

让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

image-20210418185507506

2.4 HotSpot算法实现细节

2.5 垃圾收集器

2.5.1 Serial收集器

最基础、历史最悠久的收集器。

迄今为止,它依然是HotSpot虚拟机运行在客户端模式下的默认新生 代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内 存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)[1]最小的;对于单核处理 器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以 获得最高的单线程收集效率。在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚 拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的 内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一 百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。

对于运行在客户端模式下的虚拟机来说是一个很好的选择

image-20210720171839406

2.5.2 ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本

image-20210720171958473

2.5.3 Parallel Scavenge收集器

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能
地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐 量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值

2.5.4 Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

image-20210720172155858

2.5.5 Parrallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

image-20210720172218650

2.5.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为 关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非 常符合这类应用的需求。

基于标记-清除算法实现,整个过程分为四个步骤,包括:

1)初始标记(CMS initial mark)

2)并发标记(CMS concurrent mark)

3)重新标记(CMS remark)

4)并发清除(CMS concurrent sweep)

image-20210720172313424

2.5.7 Garbage First收集器

Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

image-20210720172401674

2.5.8 Shenandoah收集器

2.5.9 ZGC收集器

ZGC(“Z”并非什么专业名词的缩写,这款收集器的名字就叫作Z Garbage Collector)是一款在JDK 11中新加入的具有实验性质[1]的低延迟垃圾收集器

2.6 内存分配与回收策略

  • 对象有现在Eden(新生代)分配

  • 大对象直接进入老生代

  • 长期存活的对象进入老生代

3. 类文件结构

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数 据,没有空隙存在。当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前的方式分割 成若干个8个字节进行存储。