JVM详解(四)——运行时数据区-堆

一、堆

1、介绍

  Java运行程序对应一个进程,一个进程就对应一个JVM实例。一个JVM实例就有一个运行时数据区(Runtime),Runtime里面,就只有一个堆,一个方法区。这里也阐述了,方法区和堆是一个进程一份。而一个进程当中,可以有多个线程,那就意味着一个进程中的多个线程会共享堆空间和方法区。
  一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。堆在JVM启动的时候被创建,其空间大小也就确定了,是JVM管理的最大一块内存空间,堆内存大小是可以调节的。
  Java虚拟机规范规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
  所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(TLAB)。

JVM详解(四)——运行时数据区-堆

  堆空间中,有一部分线程私有的缓冲区,叫TLAB,它不是所有线程共享的区域。

  《Java虚拟机规范》中对堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。其实,从实际使用角度来看,是"几乎"所有的对象实例都在这里分配内存。
  数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或数组在堆中的位置。在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。堆,是GC执行垃圾回收的重点区域。而频繁的GC会影响用户线程的执行。
  为什么是几乎?逃逸分析,会去判断在方法中对象是否发生了逃逸,如果没有的话,会在栈上分配。

JVM详解(四)——运行时数据区-堆

2、堆的内存结构

  堆空间细分

JVM详解(四)——运行时数据区-堆

  JDK7:

JVM详解(四)——运行时数据区-堆

  JDK8:

JVM详解(四)——运行时数据区-堆

  永久代-->元空间

JVM详解(四)——运行时数据区-堆

3、设置堆内存大小与OOM

  Java堆用于存储Java对象实例,在JVM启动时就已经设定好了。可以通过-Xmx和-Xms来进行设置。一旦堆区的内存大小超过-Xmx所指定的最大内存时,将会抛出OutOfMemoryError异常。

  -Xms:堆区的初始内存大小
  -Xmx:堆区的最大内存大小

  -X:是JVM的运行参数
  ms:是memory start

  通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分割计算堆区的大小,从而提高性能。
  理由:初始设置一个值之后,如果堆空间不够的话,需要不断的扩容。后续不用的话,也需要释放。那么,在服务器使用的时候,堆空间不断的扩容。在空闲的时候,也需要把堆空间做释放,那频繁的扩展和释放,会造成不必要的系统压力。
  默认情况下,初始内存大小:物理电脑内存大小/64。最大内存:物理电脑内存大小/4。
  代码示例:设置堆内存大小

 1 // 默认情况
2 public class Main {
3 public static void main(String[] args) {
4
5 // 返回Java虚拟机中的堆内存总量
6 long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
7 // 返回Java虚拟机试图使用的最大堆内存量
8 long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
9
10 System.out.println("-Xms : " + initialMemory + "M");
11 System.out.println("-Xmx : " + maxMemory + "M");
12
13 System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
14 System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
15
16 // try {
17 // Thread.sleep(1000000);
18 // } catch (InterruptedException e) {
19 // e.printStackTrace();
20 // }
21 }
22 }
23
24 // 默认值
25 // -Xms : 245M (约为 16G / 64)
26 // -Xmx : 3628M
27 // 系统内存大小为:15.3125G (这个值约等于系统内存)
28 // 系统内存大小为:14.171875G
29
30 // 设置堆参数:-Xms600m -Xmx600m
31 // -Xms : 575M
32 // -Xmx : 575M

  为什么设置的是600M。打印出来却是575M呢?查看设置的参数:
  方式一:jps / jstat -gc #{进程id}
  方式二:-XX:+PrintGCDetails
  因为统计是:eden + s0 + old

JVM详解(四)——运行时数据区-堆

4、新生代与老年代

  存储在JVM中的Java对象可以被划分为两类:①生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速。②生命周期非常长的对象,在某些极端情况下还能够与JVM的生命周期保持一致。
  如下图所示:堆空间的划分,默认,新生代:老年代 = 1:2,S0:S1:Eden = 1:1:8。
  可以通过-XX:NewRatio=2,-XX:SurvivorRatio=8来设置比例。

JVM详解(四)——运行时数据区-堆

  代码示例:查看比例关系

1 // 设置:-Xms600m -Xmx600m
2 // 可通过命令行的指令查看比例
3 jinfo -flag NewRatio #{进程id}
4 -XX:NewRatio=2
5
6 jinfo -flag SurvivorRatio #{进程id}
7 -XX:SurvivorRatio=8

  可以发现,-XX:NewRatio的值是2,并且用jvisualvm.exe查看,内存的大小也是一致的。但是-XX:SurvivorRatio的值是8,但是查看的却是6。怎么回事呢?
  这里,官网文档里给的是8,查看的也是8,但是实际运行内存分配是6。手动设置一下吧。
  几乎所有的Java对象都是在Eden区被new出来的。绝大部分的Java对象的销毁都在新生代进行。新生代中80%的对象都是朝生夕死的。
  可以使用-Xmn设置新生代的内存大小。
  通常情况下,绝大多数Java对象的生命周期都是很短的。survivor区放的就是从Eden区通过minor gc存活下来的。老年代,存放新生代中经历多次GC仍然存活的对象。

5、图解对象分配过程(重要)

  为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。图解对象分配过程:

JVM详解(四)——运行时数据区-堆

  说明:绿色,存活;红色,垃圾。
  第一次:对象在Eden区产生,Eden区满,如果再来对象,会进行一次gc,叫YGC,或者Minor GC,这时会触发一个STW(stop the world),用户线程会停止。
  这时,Eden区放了5个对象,会去判断谁是垃圾,谁不是垃圾。会清理掉红色。把绿色的全部挪到S0区,且年龄计数器(每一个对象都有一个年龄计数器age) + 1。
  第一次完毕:这时Eden区的数据被全部清空,只有S0区有两个对象。

  第二次:对象在Eden区产生,当他又满了,会又触发YGC(Minor GC)。被清理掉4个垃圾,存活1个,这时会把它放到S1区。
  同一次过程中(YGC),会把S0区的两个对象也要判断是否为垃圾,是就回收;这里不是,把S0的两个也放到S1中,且age +1。
  第二次完毕:这时Eden区的数据被全部清空,S0区被全部清空。只有S1区有三个对象。
  ……
  重复上述过程,直到。
  第N次:当对象 age 达到15(默认值)后,S1区的两个对象会晋升,它不再是挪到S0区,而是挪到老年代。

  注意:上面说到Eden区一满,如果再来对象,会进行一次gc,叫YGC,或者Minor GC。如果是S0区满了,不会触发YGC。YGC就会回收 eden + s0/s1区。
  在老年代,相对悠闲。当老年代内存不足时,会触发GC(Major GC),对老年代的内存清理。若执行之后发现依然无法进行对象的保存,会报OOM。
  关于垃圾回收:频繁在新生代,很少在老年代,几乎不在永久代/元空间。

  对象分配的特殊情况:
  ①新生代区,在没有达到15的时候,有没有可能直接晋升到老年代呢?可能的!
  ②一出生就到老年代,也是有可能的。大对象Eden区放不下的话,直接放到老年代。

JVM详解(四)——运行时数据区-堆

  代码示例:JVisualVM演示对象的分配过程

JVM详解(四)——运行时数据区-堆JVM详解(四)——运行时数据区-堆
 1 // 用JVisualVM查看各个区的内存变化情况
2 // -Xms600m -Xmx600m
3 public class Main {
4 byte[] buffer = new byte[new Random().nextInt(1024 * 200)];
5
6 public static void main(String[] args) {
7 ArrayList<Test> list = new ArrayList<>();
8 while (true) {
9 list.add(new Test());
10 try {
11 Thread.sleep(10);
12 } catch (InterruptedException e) {
13 e.printStackTrace();
14 }
15 }
16 }
17 }

分配过程

6、Minor GC、Major GC、Full GC(重要)

  调优:主要就是要减少GC次数。
  JVM在GC时,并不是每次都对新生代、老年代、方法区一起回收,大部分回收指的新生代。针对HotSpot VM的实现,它里面的GC按照回收区域分为两大类型,一种是部分收集(Partial GC),一种是整堆收集(Full GC)。
  部分收集(Partial GC):不是完整收集整个Java堆,又分为:
  (1)新生代收集(Minor GC / Young GC):只是新生代(Eden,S0,S1)的收集。
  (2)老年代收集(Major GC / Old GC):只是老年代的收集。目前,只有CMS GC会有单独收集老年代的行为。
  (3)混合收集(Mixed GC):收集整个新生代以及部分老年代。目前,只有G1 GC会有这种行为。
  整堆收集(Full GC):收集整个Java堆和方法区。
  注意:很多时候Major GC和Full GC会混淆使用,需要具体分辨是老年代回收还是整堆回收。重点关注Major GC、Full GC。因为他产生的用户线程暂停时间比Minor GC高10倍以上。

  Minor GC
  触发条件:Eden区满。S0、S1区满不会引发。回收:Eden + S0/S01。因为大多数Java对象都是朝生夕死,所有Minor GC非常频繁,回收速度也比较快。Minor GC会引发STW,暂停其他用户线程,等垃圾回收结束,用户线程才恢复运行。

JVM详解(四)——运行时数据区-堆

  Major GC
  触发条件:老年代满。对象从老年代消失时,我们说Major GC或Full GC发生了。出现了Major GC,至少会伴随至少一次Minor GC(不是绝对是,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。也就是老年代空间不足时,会先尝试Minor GC,如果还不足,则Major GC。
  Major GC的速度一般比Minor GC慢10倍以上,STW的时间也更长。
  若Major GC后,内存还不足,报OOM。

  Full GC
  触发条件:有以下5个。
  (1)调用System.gc()时,建议系统执行Full GC,但这不是必然执行的。
  (2)老年代空间不足。
  (3)方法区空间不足。
  (4)通过MinorGC后进入老年代的平均大小大于老年代的可用内存。
  (5)由Eden区,S0区向S1区复制时,对象大小大于S1可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
  Full GC是开发或调优中需要尽量避免的。
  代码示例:GC举例,用于日志分析

JVM详解(四)——运行时数据区-堆JVM详解(四)——运行时数据区-堆
 1 // 测试MinorGC、MajorGC、FullGC
2 // -Xms9m -Xmx9m -XX:+PrintGCDetails
3 public class GCTest {
4 public static void main(String[] args) {
5 int i = 0;
6 try {
7 List<String> list = new ArrayList<>();
8 String a = "baidu.com";
9 while (true) {
10 list.add(a);
11 a = a + a;
12 i++;
13 }
14 } catch (Throwable t) {
15 t.printStackTrace();
16 System.out.println("遍历次数为:" + i);
17 }
18 }
19 }

gc

7、堆空间分代思想

  为什么需要把堆空间分代?不分代就不能正常工作了吗?经研究,不同对象的生命周期不同,70%~90%的对象是临时对象。
  不分代也可以。分代的唯一理由就是优化GC性能。如果没有分代,所有的对象都在一块,就如同把一个学校的人都关在一个教室,GC的时候要找到哪些对象是垃圾,就会对堆的所有区域进行扫描。而很多对象是朝生夕死的,如果分代的话,把新创建的对象放到一个地方,GC的时候先把这块存储朝生夕死的对象的区域进行回收,会腾出很大的空间出来,也有利于提供GC效率。

JVM详解(四)——运行时数据区-堆

JVM详解(四)——运行时数据区-堆

8、内存分配策略(对象提升规则)

  针对不同年龄段的对象分配原则如下:
  (1)优先分配到Eden。
  (2)大对象直接分配到老年代,尽量避免程序中出现过多的大对象。
  (3)长期存活的对象分配到老年代。
  (4)动态对象年龄判断:如果S0区中相同年龄的所有对象大小的总和大于S0空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到年龄阈值。
  (5)空间分配担保:-XX:HandlePromotionFailure
  代码示例:大对象直接进入老年代

1 // -Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails
2 public class YoungOldAreaTest {
3 public static void main(String[] args) {
4 // 20m
5 byte[] buffer = new byte[1024 * 1024 * 20];
6 }
7 }

  结果:可以看到,这个大对象直接出现在了老年代。

JVM详解(四)——运行时数据区-堆

9、为对象分配内存:TLAB

  为什么会有TLAB(Thread Local Allocation Buffer)?堆是线程共享区域,任何线程都可以访问到堆中的共享数据。由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆中划分内存空间是不安全的,为避免多个线程操作同一地址,需要使用加锁等机制,进而会影响分配速度。
  什么是TLAB?从内存模型而不是垃圾收集的角度,对Eden区继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。多线程同时分配内存时,使用TLAB可以避免一系列的线程安全问题。同时还能够提升内存内配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
  据我所知,所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

JVM详解(四)——运行时数据区-堆

  尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。默认情况下,TLAB空间内存非常小,仅占整个Eden区的1%。一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden区中分配内存。
  代码示例:

 1 // 无参数设置。UseTLAB默认是开启的
2 public class TLABArgsTest {
3 public static void main(String[] args) {
4 System.out.println("我只是来打个酱油~");
5
6 try {
7 Thread.sleep(1000_000);
8 } catch (InterruptedException e) {
9 e.printStackTrace();
10 }
11 }
12 }

JVM详解(四)——运行时数据区-堆

  对象分配过程:

JVM详解(四)——运行时数据区-堆

二、逃逸分析

1、介绍

  堆是分配对象的唯一选择吗?不是。栈上分配,标量替换。
  在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:
  随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么"绝对"了。
  在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是有一种特殊情况,就是如果经过逃逸分析后发现,这个对象并没有逃逸出方法,那么就可能被优化成栈上分配。这样就无须在堆上分配内存,也无须进行垃圾回收了,这也是最常见的堆外存储技术。
  此外,前面提到的基于OpenJDK深度定制的TaoBaoVM,其中创建的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。

2、逃逸分析:代码优化

  使用逃逸分析,编译器可以对代码做如下优化:
  栈上分配:将堆分配转化为栈分配,如果一个对象在子程序中被分配,如果指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
  同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
  标量替换(分离对象):有的对象可能不需要作为一个连续的内存结构存在,也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
  这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
  逃逸分析的基本行为就是分析对象动态作用域:
  (1)当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  (2)当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他方法中。
  代码示例:没有逃逸

1 public void method1(){
2 V v = new V();
3 // use v
4 // ...
5 v = null;
6 }

  没有发生逃逸的对象,则可以分配到站上,随着方法执行结束,栈空间就被移除。

JVM详解(四)——运行时数据区-堆

  如何快速的判断是否发生了逃逸?就看new的对象实体是否有可能在方法外被调用。

JVM详解(四)——运行时数据区-堆

3、栈上分配

  JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
  代码示例:栈上分配

 1 // -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
2 public class StackAllocation {
3 public static void main(String[] args) {
4 long start = System.currentTimeMillis();
5
6 for (int i = 0; i < 10000000; i++) {
7 alloc();
8 }
9
10 // 查看执行时间
11 long end = System.currentTimeMillis();
12 System.out.println("花费的时间为: " + (end - start) + " ms");
13
14 // 为了方便查看堆内存中对象个数,线程sleep
15 try {
16 Thread.sleep(1000_000);
17 } catch (InterruptedException e1) {
18 e1.printStackTrace();
19 }
20 }
21
22 private static void alloc() {
23 //未发生逃逸
24 User user = new User();
25 }
26
27 static class User {
28
29 }
30 }
31
32 // -XX:-DoEscapeAnalysis
33 // 花费的时间为:108 ms
34
35 // -XX:+DoEscapeAnalysis
36 // 花费的时间为:4 ms

  结果:未开启逃逸分析

JVM详解(四)——运行时数据区-堆

  结果:开启逃逸分析

JVM详解(四)——运行时数据区-堆

1 // -Xmx256m -Xms256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
2 // 不开启逃逸分析,这种情况,有GC
3
4 // -Xmx256m -Xms256m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
5 // 开启逃逸分析,这种情况,没有GC

4、同步省略

  线程同步的代价是相当高的,同步的后果是降低并发性和性能。
  在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。

JVM详解(四)——运行时数据区-堆

5、标量替换

  标量:指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
  聚合量:相对标量,还可以再分解的数据。Java中的对象就是聚合量。
  标量替换:在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个标量来代替。这个过程就是标量替换,表示允许将对象打散分配到栈上。

JVM详解(四)——运行时数据区-堆

  可以看到,Point这个聚合量经过逃逸分析后,发现它没有逃逸,就被替换成两个标量了。
  标量替换的好处就是可以大大减少堆内存的占用。因为一旦需要创建对象了,那么就不再需要分配堆内存了。标量替换为栈上分配提供了很好的基础。
  代码示例:标量替换

JVM详解(四)——运行时数据区-堆JVM详解(四)——运行时数据区-堆
 1 public class Main {
2
3 public static void main(String[] args) {
4 long start = System.currentTimeMillis();
5
6 for (int i = 0; i < 10000000; i++) {
7 alloc();
8 }
9
10 long end = System.currentTimeMillis();
11 System.out.println("花费的时间为: " + (end - start) + " ms");
12 }
13
14 public static void alloc() {
15 User u = new User(); // 未发生逃逸
16 u.id = 5;
17 u.name = "www.baidu.com";
18 }
19
20 static class User {
21 public int id;
22 public String name;
23 }
24
25 }

标量替换

  结果:

JVM详解(四)——运行时数据区-堆JVM详解(四)——运行时数据区-堆
 1 // -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC
2 // -XX:-EliminateAllocations
3 // 一、开启逃逸分析,不开启标量替换,有GC
4 [GC (Allocation Failure) 25600K->784K(98304K), 0.0012734 secs]
5 [GC (Allocation Failure) 26384K->736K(98304K), 0.0031470 secs]
6 [GC (Allocation Failure) 26336K->720K(98304K), 0.0007684 secs]
7 [GC (Allocation Failure) 26320K->720K(98304K), 0.0007718 secs]
8 [GC (Allocation Failure) 26320K->720K(98304K), 0.0006689 secs]
9 [GC (Allocation Failure) 26320K->752K(101376K), 0.0007525 secs]
10 [GC (Allocation Failure) 32496K->692K(101376K), 0.0006699 secs]
11 [GC (Allocation Failure) 32436K->692K(101376K), 0.0002453 secs]
12 花费的时间为: 69 ms
13
14 // -XX:+EliminateAllocations
15 // 二、开启逃逸分析,开启标量替换,无GC
16 花费的时间为: 4 ms

结果

6、小结

  逃逸分析并不成熟。其根本原因就是无法保证逃逸分析的性能消耗一定能高于其他的消耗。虽然经过逃逸分析可以做栈上分配、标量替换、锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
  一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的,那这个逃逸分析的过程就白白浪费了。
  虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
  JVM会在栈上分配不会逃逸的对象,理论是可行的,但是取决于JVM设计者的选择。据我所知,Hot Spot JVM并没有这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上的。
  intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元空间取代。但是,intern字符串缓存和静态变量并没有被转移到元空间,而是在堆上分配。所以这一点同样符合前面的结论:对象实例都是分配在堆上的。

上一篇:JVM笔记【1】-- 运行时数据区


下一篇:JVM 专题十二:运行时数据区(七)对象的实例化内存布局与访问定位