JVM基础 - 入门篇

JDK体系结构

JVM内存模型

一张图描述JVM内存模型

JVM包括两个子系统和两个组件:

两个子系统

  • ClassLoader(类装载)
    根据给定的全限定名类名(如:java.lang.Object)来装载 class文件 到 运行时数据区 中的 方法区。程序中可以继承 java.lang.ClassLoader 类来实现自己的ClassLoader。

  • ExecutionEngine(执行引擎)
    执行classes中的指令。任何JVM specification实现(JDK)的核心都是Execution engine,不同的JDK例如Sun的JDK和IBM的JDK好坏主要就取决于他们各自实现的Execution engine的好坏。

两个组件

  • Native Interface(本地接口)
    与native libraries交互,是其它编程语言交互的接口。当调用native方法的时候,就进入了一个全新的并且不再受虚拟机限制的世界,所以也很容易出现JVM无法控制的native heap OutOfMemory。

  • Runtime Data Area(运行时数据区)
    这就是我们常说的JVM的内存。主要分为五个部分:

    方法区

    有时候也成为永久代,该区域是被线程共享的。

  • 作用
    方法区主要用来存储已被虚拟机加载的类的信息常量静态变量即时编译器(JIT)编译后的代码 等数据.

  • GC
    在该区内很少发生垃圾回收,但是并不代表不发生GC,在这里进行的GC主要是 对方法区里的常量池和对类型的卸载,但回收效率很低,当方法区无法满足内存需求时,会报 OOM 异常;

方法区里有一个运行时常量池,用于存放静态编译产生的字面量和符号引用。该常量池具有动态性,也就是说常量并不一定是编译时确定,运行时生成的常量也会存在这个常量池中。

方法区和元数据区是不同jdk版本对JVM协议的不同实现;

虚拟机栈

虚拟机栈也就是我们平常所称的栈内存, 它为java方法服务,每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表操作数栈动态链接方法出口等信息。
-w728

  • 虚拟机栈
    是线程私有的,它的生命周期与线程相同。每个方法从调用到执行过程,就对应着栈桢在虚拟机栈中从入栈到出栈的过程。

  • 栈桢
    虚拟机栈由多个栈桢(Stack Frame)组成。一个线程会执行一个或多个方法,一个方法对应一个栈桢。

  • 局部变量表
    局部变量表里存储的是基本数据类型returnAddress类型(指向一条字节码指令的地址)和对象引用,这个对象引用有可能是指向对象起始地址的一个指针,也有可能是代表对象的句柄或者与对象相关联的位置。局部变量所需的内存空间在编译器间确定

  • 操作数栈
    操作数栈的作用主要用来存储运算结果以及运算的操作数,它不同于局部变量表通过索引来访问,而是压栈和出栈的方式

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接. 动态链接就是将常量池中的符号引用在运行期转化为直接引用

本地方法栈

本地方法栈和虚拟机栈类似,虚拟机栈是为虚拟机执行Java方法而准备的,而本地方法栈为虚拟机执行Native本地方法而准备的。

堆(Heap)

Java堆是所有线程所共享的一块内存。

在虚拟机启动时创建,几乎所有的对象实例、数组都在这里存放,对于大多数应用来说,堆是JVM管理的内存中最大的一块区域,也是最容易发生OOM的区域,因此该区域经常发生垃圾回收操作。

大多数JVM都会将堆实现为大小可扩展的,通过-Xmx、-Xms等参数控制。

新生的对象默认放在Eden区, Eden区满了会触发minor GC/yong GC;

程序计数器

占用内存空间小,字节码解释器工作时通过改变这个计数值可以选取下一条需要执行的字节码指令分支、循环、跳转、异常处理和线程恢复等功能都需要依赖这个计数器完成。该内存区域是唯一一个java虚拟机规范没有规定任何OOM情况的区域。

JVM内存快照示例

基于上述原理,现在写一个简单的代码来举例描述下具体各个区域是怎么分配的。

Math.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Math {
public static final int initData = 666;

public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}

public static void main(String[] args) {
Math math = new Math();
int result = math.compute();
System.out.println(result);
}
}

javap是jdk自带的一个工具在jdk安装目录的/bin下面可以找到,可以对代码反编译,也可以查看java编译器生成的字节码,对代码的执行过程进行分析,了解jvm内部的工作。

通过以下指令可以得到Java字节码指令:

javac Math.java
javap -c Math.class > Math.txt

Math.txt(为了便于编译,Math.txt中去掉了User相关内容)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Compiled from "Math.java"
public class Math {
public static final int initData;
public static User user = new User();

public Math();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public int compute();
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn

public static void main(java.lang.String[]);
Code:
0: new #2 // class Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method compute:()I
12: istore_2
13: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
16: iload_2
17: invokevirtual #6 // Method java/io/PrintStream.println:(I)V
20: return
}

通过查询javap 字节码指令集,可以看到每一步操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
栈和局部变量操作 
将常量压入栈的指令
aconst_null 将null对象引用压入栈
iconst_m1 将int类型常量-1压入栈
iconst_0 将int类型常量0压入栈
iconst_1 将int类型常量1压入栈
iconst_2 将int类型常量2压入栈
iconst_3 将int类型常量3压入栈
iconst_4 将int类型常量4压入栈
iconst_5 将int类型常量5压入栈
lconst_0 将long类型常量0压入栈
lconst_1 将long类型常量1压入栈
fconst_0 将float类型常量0压入栈
fconst_1 将float类型常量1压入栈
dconst_0 将double类型常量0压入栈
dconst_1 将double类型常量1压入栈
bipush 将一个8位带符号整数压入栈
sipush 将16位带符号整数压入栈
....

此时内存区域如下图所示:

垃圾回收

Minor GC和Full GC区别

  • Minor GC/Young GC: 指新生代发生的垃圾收集动作,Minor GC非常频繁,回收速度一般比较快;
  • Major GC/Full GC: 一般会回收老年代,年轻代,方法区(永久区)的垃圾,Major GC的速度一般会比Minor GC慢10倍以上。

什么时候回收

  • Minor GC触发条件
    当Eden区满时,触发Minor GC。

  • Full GC触发条件

    • (1)调用System.gc时,系统建议执行Full GC,但是不必然执行

    • (2)老年代空间不足

    • (3)方法区空间不足

    • (4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存

    • (5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小、

      怎么回收

      从GC的底层机制可以看出,对于可以搜索到的对象进行复制操作,对于搜索不到的对象,调用finalize()方法进行释放

具体过程:当GC线程启动时,会通过可达性分析法把Eden区和From Space区的存活对象复制到To Space区,然后把Eden Space和From Space区的对象释放掉。当GC轮训扫描To Space区一定次数后,把依然存活的对象复制到老年代,然后释放To Space区的对象。

对于用可达性分析法搜索不到的对象,GC并不一定会回收该对象。要完全回收一个对象,至少需要经过两次标记的过程

  • 第一次标记
    对于一个没有其他引用的对象,筛选该对象是否有必要执行finalize()方法,如果没有执行必要,则意味可直接回收。(筛选依据:是否复写或执行过finalize()方法;因为finalize方法只能被执行一次)。

  • 第二次标记
    如果被筛选判定位有必要执行,则会放入FQueue队列,并自动创建一个低优先级的finalize线程来执行释放操作。如果在一个对象释放前被其他对象引用,则该对象会被移除FQueue队列。

JVM内存分配与回收策略

对象优先在Eden区分配

大多数情况下,对象在新生代中Eden区分配,当Eden区没有足够空间时,虚拟机将发起一次Minor GC.

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

  • 既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须识别哪些对象应该放在新生代,哪些需要放在老年代。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

  • 如果对象在Eden区出生并经过一次minor gc后仍然存活,并且大小能被Survivor容纳的话,将会移动到另一个Survivor区,并将对象年龄设为1。对象在Survivor中每熬过一次minor gc,年龄就增1岁,当增加到一定大小(默认为15岁),就会晋升到老年代。对象晋升到老年代的年龄阈值可以通过-XX:MaxTenuringThreshold来设置。

大对象直接进入老年代

  • 大对象就是需要大量连续内存空间的对象,比如:长字符串、数组

  • JVM参数-神器:PretenureSizeThreshold可以设置大对象的大小,如果对象超过了设置大小,在创建时就会直接进入老年代,不会进入年轻代,这个参数只在SerialParNew两个收集器下有效

  • 例如 -神器:PretenureSizeThreshold=1000000 -XX:+UseSerialGC

  • 这么做的目的:避免为大对象分配内存时的复制操作而降低效率

对象动态年龄判断

  • 当前放置对象的Survivor区域里(另一块Survivor为空),一批对象的总大小大于这块Survivor区域内存大小的50%,那么此时大于这批对象年龄最大值的对象,就可以直接提前进入老年代了;

  • 例如Survivor区域里现有一批对象:年龄为1对象 + 年龄为2对象 + ... + 年龄为n对象 所占空间总和超过了Survivor区总大小的50%,此时就会把年龄大于n的对象提前放入老年代;

老年代空间分配担保机制

年轻代每次Minor gc之前,JVM都会计算下老年代剩余可用空间:

  • 如果老年代剩余可用空间小于年轻代里现有的所有对象的大小之和(包括垃圾对象),就会看一个 -神器:-HandlePromotionFailure (JDK1.8默认就设置了)的参数是否配置了,如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每次minor gc后进入老年代的对象平均大小;

  • 如果上一步结果是小于或者没有设置该参数,JVM就会发起一次Full GC,对老年代和年轻代一起进行垃圾回收;

  • 如果上一步结果是大于该参数,正常进行Minor GC;当然如果Minor GC后,剩余存活对象里需要移动到老年代的总大小超过了老年代可用空间,还是会触发Full GC;

  • 如果回收完还是没有足够空间存放新建的对象,就会发生 OOM

垃圾对象判断

如何判断一个对象是否可以被回收,常见的做法有两种:

引用计数法

给对象头中添加一个引用计数器,每当有一个地方引用它,就给它的计数器+1;当引用失效,计数器就-1;只要计数器为0,就表示当前对象没有被使用,可以被回收。

  • 优点
    引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。

  • 缺点
    无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0.

Java的引用类型一般分为四种:

  • 强引用
    普通的变量引用;

  • 软引用
    将对象用SoftReference软引用类型的对象包裹,正常情况下不会被回收,但是GC做完之后发现释放不出空间存放新对象,就会把这些软引用的对象回收掉。软引用可用来实现对内存敏感度不高的高速缓存。

    1
    public static SoftReference<User>  user = new SoftReference<User>(new User());
  • 弱引用
    将对象用WeakReference弱引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用。

    1
    public static WeakReference<User> user = new WeakReference<User>(new User());
  • 虚引用
    也成为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用。

可达性分析算法

这个算法的基本思想是通过一系列被称为”GC Roots”的对象作为起始点,从这些节点向下搜索,找到的对象都标记为非垃圾对象,剩余的都为垃圾对象;

GC Roots: 线程栈的本地变量、静态属性、常量、本地方法栈的变量等

finalize()方法最终判定对象是否存活
即使在可达性分析算法中不可达的对象,也并非是 『非死不可』,标记完之后只是暂时处于 『缓刑』 阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。

标记的前提是:对象在进行可达性分析之后发现没有与任何GC Roots相连的引用链

  • 第一次标记并进行一次筛选
    对象如果没有覆盖finalize()方法,将会直接被回收;

  • 第二次标记
    如果这个对象覆盖了finalize()方法,只要在该方法中重新与引用链上的任何一个对象建立了关联,就可以拯救自己,不会被回收。

如何判断一个类是无用类:
类需要同时满足下面3个条件才能算是 无用类

  • 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例;

  • 加载该类的ClassLoader已经被回收;

  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;

垃圾收集算法

常用的垃圾回收算法有:标记-清除算法、复制算法、标记-整理算法、分代收集算法。目前主流的JVM(HotSpot)采用的是分代收集算法。

标记-清除算法

标记-清除算法采用从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片

复制算法

标记-整理算法

分代收集算法

垃圾收集器

Serial收集器

ParNew收集器

Parallel Scavenge收集器

CMS收集器

G1收集器

JVM优化

能否对JVM调优,让其几乎不发生Full GC?

评估对象大小和生命周期,调整年轻代大小和Eden/Survivor区比例,保证minor gc就能够回收基本所有对象,避免对象因为过大或年龄太大进入老年代。

需要放入survivor区的对象大于survivor区大小的50%时,会触发担保机制,直接放入老年代;

扩展阅读

【搞定Jvm面试】Java 内存区域揭秘附常见面试题解析