JVM虚拟机之堆空间
堆与进程
- 堆针对一个 JVM 进程来说是唯一的。也就是一个进程只有一个 JVM 实例,一个 JVM 实例中就有一个运行时数据区,一个运行时数据区只有一个堆和一个方法区。
- 但是进程包含多个线程,他们是共享同一堆空间的。

-
一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域。
-
Java 堆区在 JVM 启动的时候即被创建,其空间大小也就确定了,堆是 JVM 管理的最大一块内存空间,并且堆内存的大小是可以调节的。
-
《Java 虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
-
所有的线程共享 Java 堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
-
《Java 虚拟机规范》中对 Java 堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)。但是,从实际使用角度看:“几乎”所有的对象实例都在堆分配内存,但并非全部。因为还有一些对象是在栈上分配的(逃逸分析,标量替换)。
-
数组和对象可能永远不会存储在栈上(不一定),因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
-
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
- 也就是触发了GC的时候,才会进行回收。
- 如果堆中对象马上被回收,那么用户线程就会收到影响,因为有 stop the word。
-
堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
-
随着JVM的迭代升级,原来一些绝对的事情,在后续版本中也开始有了特例,变的不再那么绝对。
public class SimpleHeap {
private int id;//属性、成员变量
public SimpleHeap(int id) {
this.id = id;
}
public void show() {
System.out.println("My ID is " + id);
}
public static void main(String[] args) {
SimpleHeap sl = new SimpleHeap(1);
SimpleHeap s2 = new SimpleHeap(2);
int[] arr = new int[10];
Object[] arr1 = new Object[10];
}
} 
堆内存细分
- Java7 及之前堆内存逻辑上分为三部分:年轻代、老年代、永久代(方法区、也成非堆空间)
- Young Generation Space 年轻代 Young/New
- 又被划分为 Eden 区和 Survivor 区,其中 Survivor 区又划分成S0区和S1区。
- Old generation space 老年代 Old/Tenure
- Permanent Space 永久代 Perm
- Young Generation Space 年轻代 Young/New
- Java 8 及之后堆内存逻辑上分为三部分:年轻代、老年代、元空间。其中年轻代和老年代基本没什么变化,但是永久代消失了,用Meta Space 来取代了永久代。


JVisualVM可视化查看堆内存
public class HeapDemo {
public static void main(String[] args) {
System.out.println("start...");
try {
TimeUnit.MINUTES.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
}
} 


设置堆内存大小与 OOM
设置堆内存
-
Java 堆区用于存储 Java 对象实例,那么堆的大小在 JVM 启动时就已经设定好了,大家可以通过选项”-Xms”和”-Xmx”来进行设置。
-Xms:用于表示堆区的起始内存,等价于-XX:InitialHeapSize-Xmx:则用于表示堆区的最大内存,等价于-XX:MaxHeapSize
-
一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出 OutOfMemoryError 异常。
-
通常会将
-Xms和-Xmx两个参数配置相同的值。原因是如果两个配置不一样,初始内存小,最大内存大。在运行期间如果堆内存不够用了,会一直扩容直到最大内存。如果内存够用且多了,也会不断的缩容释放。频繁的扩容和释放造成不必要的压力,避免在GC之后调整堆内存给服务器带来压力。如果两个设置一样的就少了频繁扩容和缩容的步骤,内存不够了就直接报OOM。 -
默认情况下:
- 初始内存大小:物理电脑内存大小/64
- 最大内存大小:物理电脑内存大小/4
/**
* 1. 设置堆空间大小的参数
* -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
* -X 是jvm的运行参数
* ms 是memory start
* -Xmx 用来设置堆空间(年轻代+老年代)的最大内存大小
*
* 2. 默认堆空间的大小
* 初始内存大小:物理电脑内存大小 / 64
* 最大内存大小:物理电脑内存大小 / 4
* 3. 手动设置:-Xms600m -Xmx600m
* 开发中建议将初始堆内存和最大的堆内存设置成相同的值。
*
* 4. 查看设置的参数:方式一: jps / jstat -gc 进程id
* 方式二:-XX:+PrintGCDetails
*/
public class HeapSpaceInitial {
public static void main(String[] args) {
//返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
//返回Java虚拟机试图使用的最大堆内存量
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms : " + initialMemory + "M");
System.out.println("-Xmx : " + maxMemory + "M");
System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} -Xms : 123M
-Xmx : 1794M
系统内存大小为:7.6875G
系统内存大小为:7.0078125G 
public class HeapSpaceInitial {
public static void main(String[] args) {
//返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
//返回Java虚拟机试图使用的最大堆内存量
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms : " + initialMemory + "M");
System.out.println("-Xmx : " + maxMemory + "M");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} -Xms : 575M
-Xmx : 575M jps:查看java进程jstat:查看某进程内存使用情况

SOC: S0 区总共容量
S1C: S1 区总共容量
S0U: S0 区使用的量
S1U: S1 区使用的量
EC: Eden 区总共容量
EU: Eden 区使用的量
OC: 老年代总共容量
OU: 老年代使用的量 - 首先统计整个堆区的内存总量:25600(S0)+25600(S1)+153600(Eden)+409600(Old) = 614400K,614400/1024 = 600M,可见总量和设置的参数是对得上的。
- 然后去除 S1 区使用的内存总量:25600(S0)+153600(Eden)+409600(Old) = 588800K,588800 /1024 = 575M,可见并非巧合,S0区和 S1 区两个只有一个能使用,另一个用不了(后面会详解)。

OOM
public class OOMTest {
public static void main(String[] args) {
ArrayList<Picture> list = new ArrayList<>();
while(true){
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
list.add(new Picture(new Random().nextInt(1024 * 1024)));
}
}
}
class Picture{
private byte[] pixels;
public Picture(int length) {
this.pixels = new byte[length];
}
} -Xms600m -Xmx600mException in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.atguigu.java.Picture.<init>(OOMTest.java:29)
at com.atguigu.java.OOMTest.main(OOMTest.java:20)
Process finished with exit code 1 

年轻代与老年代
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速(朝生夕死)。
- 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与 JVM 的生命周期保持一致。
- 其中年轻代又可以划分为 Eden 空间、Survivor0 空间和 Survivor1 空间(有时也叫做 From 区、To 区)

- 默认
-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3,可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5

- 在 HotSpot 中,Eden 空间和另外两个 Survivor 空间缺省所占的比例是 8 : 1 : 1,当然开发人员可以通过选项
-XX:SurvivorRatio调整这个空间比例,比如-XX:SurvivorRatio=8。 - 几乎所有的 Java 对象都是在 Eden 区被 new 出来的(一些大对象在新生代放不下时,如果老年代空间足够就直接在老年代创建,否则就会出现 OOM 异常)
- 绝大部分的 Java 对象的销毁都在新生代进行了(有些大的对象在 Eden 区无法存储时候,将直接进入老年代),IBM公司的专门研究表明,新生代中 80% 的对象都是“朝生夕死”的。
- 可以使用选项
-Xmn设置新生代最大内存大小,但这个参数一般使用默认值就可以了。

/**
* -Xms600m -Xmx600m
*
* -XX:NewRatio : 设置新生代与老年代的比例。默认值是2.
* -XX:SurvivorRatio :设置新生代中Eden区与Survivor区的比例。默认值是8
* -XX:-UseAdaptiveSizePolicy :关闭自适应的内存分配策略 (暂时用不到)
* -Xmn:设置新生代的空间的大小。 (一般不设置)
*
* @author shkstart shkstart@126.com
* @create 2020 17:23
*/
public class EdenSurvivorTest {
public static void main(String[] args) {
System.out.println("我只是来打个酱油~");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} 图解对象分配过程
- new 的对象先放 Eden 区,此区有大小限制。
- 当 Eden 的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对 Eden 区进行垃圾回收(MinorGC),将 Eden 中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到 Eden 区。
- 然后将 Eden 中的剩余对象移动到 S0 区。
- 如果再次触发垃圾回收,此时上次幸存下来的放到 S0 区的,如果没有回收,就会放到 S1 区。
- 如果再次经历垃圾回收,此时会重新放回 S0 区,接着再去 S1 区,两个交替使用,其中一个为空闲状态。
- 那什么时候可以晋升到老年代呢?年轻代进入老年代的年龄限制默认是15。该阈值可以手动设置,通过设置 JVM 参数:
-XX:MaxTenuringThreshold=N进行设置 - 在老年代 GC 的频率就很低了,老年代的对象基本是很稳定,很难被回收的。当老年代内存不足时,再次触发GC:Major GC,进行老年代的内存清理。若执行了Major GC 之后,发现依然无法进行对象的保存,就会产生 OOM 异常。
图解对象分配(一般情况)



特殊情况说明
- 如果来了一个新对象,先看看 Eden 是否放的下?
- 如果 Eden 放得下,则直接放到 Eden 区
- 如果 Eden 放不下,则触发 YGC ,执行垃圾回收,看看还能不能放下?
- 将对象放到老年区又有两种情况:
- 如果 Eden 执行了 YGC 还是无法放不下该对象,那没得办法,只能说明是超大对象,只能直接放到老年代。
- 那万一老年代都放不下,则先触发 FullGC ,再看看能不能放下,放得下最好,但如果还是放不下,那只能报 OOM
- 如果 Eden 区满了,将对象往幸存区拷贝时,发现幸存区放不下啦,那只能便宜了某些新对象,让他们直接晋升至老年代。

常用调优工具
- JDK命令行
- Eclipse:Memory Analyzer Tool
- Jconsole
- Visual VM(实时监控,推荐)
- Jprofiler(IDEA插件)
- Java Flight Recorder(实时监控)
- GCViewer
- GCEasy
GC分类
- 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC/Young GC):只是新生代(Eden,S0,S1)的垃圾收集
- 老年代收集(Major GC/Old GC):只是老年代的圾收集。目前,只有CMS GC会有单独收集老年代的行为。注意,很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收。
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。目前,只有G1 GC会有这种行为。
- 整堆收集(Full GC):对整个 Java 堆和方法区的垃圾收集。
Young GC
-
当年轻代空间不足时,就会触发 Minor GC,这里的年轻代满指的是 Eden 代满。Survivor 满了不会主动引发 GC,在 Eden 区满的时候,会顺带触发 S0 区的 GC,也就是被动触发 GC(每次Minor GC会清理年轻代的内存)。
-
因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
-
Minor GC 会引发 STW(Stop The World),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。

Major/Full GC
- 指发生在老年代的GC,对象从老年代消失时,我们说 “Major Gc” 或 “Full GC” 发生了。
- 出现了 MajorGc,经常会伴随至少一次的 Minor GC。(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程)
- 也就是在老年代空间不足时,会先尝试触发Minor GC。对此ChatGPT的解释是这样的:由于 Major GC 主要针对老年代,因此只有在确保新生代中所有存活对象都被移动到老年代后,才能进行 Major GC。另外,由于 Minor GC 的速度通常比 Major GC 要快,因此在老年代空间不足时,尝试先进行一次 Minor GC 可以更快地释放一部分内存空间。这样可以减轻后续的 Major GC 对应用程序的影响。
- Major GC 的速度一般会比 Minor GC 慢 10 倍以上,STW 的时间更长。
- 如果 Major GC 后,内存还不足,就报 OOM 了。
- 调用 System.gc() 时,系统建议执行 FullGC,但是不必然执行。
- 老年代空间不足。
- 方法区空间不足。
- 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
- 由 Eden 区、Survivor Space0(From Space)区向 Survivor s、Space1(To Space)区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
-
Major GC 是指年老代的垃圾回收
-
Full GC 则是指整个堆内存的垃圾回收,包括新生代和年老代的对象以及方法区。
GC日志分析
/**
* 测试MinorGC 、 MajorGC、FullGC
* -Xms9m -Xmx9m -XX:+PrintGCDetails
* @author shkstart shkstart@126.com
* @create 2020 14:19
*/
public class GCTest {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "atguigu.com";
while (true) {
list.add(a);
a = a + a;
i++;
}
} catch (Throwable t) {
t.printStackTrace();
System.out.println("遍历次数为:" + i);
}
}
}
[GC (Allocation Failure) [PSYoungGen: 2037K->504K(2560K)] 2037K->728K(9728K), 0.0455865 secs] [Times: user=0.00 sys=0.00, real=0.06 secs]
[GC (Allocation Failure) [PSYoungGen: 2246K->496K(2560K)] 2470K->1506K(9728K), 0.0009094 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2294K->488K(2560K)] 3305K->2210K(9728K), 0.0009568 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 1231K->488K(2560K)] 7177K->6434K(9728K), 0.0005594 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 488K->472K(2560K)] 6434K->6418K(9728K), 0.0005890 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 472K->0K(2560K)] [ParOldGen: 5946K->4944K(7168K)] 6418K->4944K(9728K), [Metaspace: 3492K->3492K(1056768K)], 0.0045270 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 0K->0K(1536K)] 4944K->4944K(8704K), 0.0004954 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
at java.lang.StringBuilder.append(StringBuilder.java:136)
at com.atguigu.java1.GCTest.main(GCTest.java:20)
[PSYoungGen: 0K->0K(1536K)] [ParOldGen: 4944K->4877K(7168K)] 4944K->4877K(8704K), [Metaspace: 3492K->3492K(1056768K)], 0.0076061 secs] [Times: user=0.00 sys=0.02, real=0.01 secs]
遍历次数为:16
Heap
PSYoungGen total 1536K, used 60K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
eden space 1024K, 5% used [0x00000000ffd00000,0x00000000ffd0f058,0x00000000ffe00000)
from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
to space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
ParOldGen total 7168K, used 4877K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
object space 7168K, 68% used [0x00000000ff600000,0x00000000ffac3408,0x00000000ffd00000)
Metaspace used 3525K, capacity 4502K, committed 4864K, reserved 1056768K
class space used 391K, capacity 394K, committed 512K, reserved 1048576K [GC (Allocation Failure) [PSYoungGen: 2037K->504K(2560K)] 2037K->728K(9728K), 0.0455865 secs] [Times: user=0.00 sys=0.00, real=0.06 secs]
-
[PSYoungGen: 2037K->504K(2560K)]:年轻代总空间为 2560K ,当前占用 2037K ,经过垃圾回收后剩余504K
-
2037K->728K(9728K):堆内存总空间为 9728K ,当前占用2037K ,经过垃圾回收后剩余728K
堆空间分代思想
- 新生代:有 Eden、两块大小相同的 Survivor(又称为 From/To 或 S0/S1)构成,To总为空。
- 老年代:存放新生代中经历多次 GC 仍然存活的对象,或者新生代容纳不了的超大对象,所以要避免创建大对象。


- 如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC 的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。(性能低)
- 而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当 GC 的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。(多回收新生代,少回收老年代,性能会提高很多)
对象内存分配策略
- 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。
- 对象在 Survivor 区中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代。
- 对象晋升老年代的年龄阀值,可以通过选项 **-XX:MaxTenuringThreshold **来设置。
-
优先分配到 Eden:开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发 Major GC 的次数比 Minor GC 要更少,因此可能回收起来就会比较慢。
-
大对象直接分配到老年代:尽量避免程序中出现过多的大对象
-
长期存活的对象分配到老年代
-
动态对象年龄判断:如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
-
空间分配担保:
-XX:HandlePromotionFailure。用于指定当新生代对象无法晋升到老年代时所采取的垃圾回收策略。在 JVM 中,当新生代中的对象经过多次垃圾回收后仍然存活时,它们将会被晋升到老年代中。但是,如果老年代没有足够的空间来容纳这些对象,就会引发一次Full GC,导致应用程序的性能下降。为了避免这种情况的发生,JVM 提供了
-XX:HandlePromotionFailure参数,可以为其设置以下三种值:false:表示当新生代中的对象无法晋升到老年代时,直接触发Full GC。true(默认值):表示当新生代中的对象无法晋升到老年代时,会尝试进行Minor GC,并且重新计算晋升老年代的对象年龄阈值。always:表示老年代总是可用的,即使是在GC期间也是如此,因此在使用该参数时需要注意 JVM 内存大小的设置,以避免内存不足的问题。
需要注意的是,使用
-XX:HandlePromotionFailure参数可能会对应用程序的性能产生影响,因此在实际应用中需要根据具体情况进行权衡和调优。
TLAB为对象分配内存(保证线程安全)
为什么有 TLAB
什么是 TLAB(Thread Local Allocation Buffer)
- 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
- 基本所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计。

TLAB再说明
-
尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。
-
在程序中,开发人员可以通过选项
-XX:UseTLAB设置是否开启TLAB空间。 -
默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项
-XX:TLABWasteTargetPercent设置TLAB 空间所占用 Eden 空间的百分比大小。 -
一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。
1、哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完 了,分配新的缓存区时才需要同步锁定 ----这是《深入理解JVM》—第三版里说的
2、和这里讲的有点不同。我猜测说的意思是某一次分配,如果TLAB用完了,那么这一次先在Eden区直接分配。空闲下来后再加锁分配新的TLAB(TLAB内存较大,分配时间应该较长)
ChatGPT:
在JVM中,一个线程可以有多个TLAB(Thread Local Allocation Buffer),每个TLAB都是该线程专用的内存分配缓存区域。每个TLAB包含一定数量的连续内存空间,用于存储对象实例。当线程需要分配对象时,它会首先尝试从自己的TLAB中分配内存。如果当前的TLAB不足以容纳新的对象,则线程将尝试从共享的Eden空间或者老年代中分配内存。
对于一些较大的对象,如果其大小超过了某个阈值,JVM可能会直接在堆空间中分配内存而不是使用TLAB进行分配,这样可以避免浪费TLAB的空间。同时,在某些情况下,JVM也可能根据性能和内存使用的考虑,动态调整TLAB的大小,以便更好地满足应用程序的需求。

堆空间参数设置
常用参数设置
官方文档:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
| 参数配置 | 参数说明 |
|---|---|
| -XX:+PrintFlagsInitial | 查看所有的参数的默认初始值 |
| -XX:+PrintFlagsFinal | 查看所有的参数的最终值(可能会存在修改,不再是初始值) |
| -Xms | 初始堆空间内存 (默认为物理内存的1/64) |
| -Xmx | 最大堆空间内存(默认为物理内存的1/4) |
| -Xmn | 设置新生代的大小。(初始值及最大值) |
| -XX:NewRatio | 配置新生代与老年代在堆结构的占比 |
| -XX:SurvivorRatio | 设置新生代中 Eden 和 S0/S1 空间的比例 |
| -XX:MaxTenuringThreshold | 设置新生代垃圾的最大年龄 |
| -XX:+PrintGCDetails | 输出详细的GC处理日志 |
| -XX:+PrintGC/-verbose:gc | 打印gc简要信息 |
| -XX:HandlePromotionFailure | 是否设置空间分配担保 |
空间分配担保
- 如果大于,则此次 Minor GC 是安全的
- 如果小于,则虚拟机会查看
-XX:HandlePromotionFailure设置值是否允担保失败。- 如果
HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。- 如果大于,则尝试进行一次Minor GC,但这次 Minor GC 依然是有风险的。
- 如果小于,则进行一次Full GC。
- 如果
HandlePromotionFailure=false,则进行一次Full GC。
- 如果
HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察 OpenJDK 中的源码变化,虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。JDK6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。即 HandlePromotionFailure=true。堆是分配对象的唯一选择么?
-
随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
-
在 Java 虚拟机中,对象是在 Java 堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
-
此外,前面提到的基于 OpenJD K深度定制的 TaoBao VM,其中创新的 GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的 Java 对象从 heap 中移至 heap 外,并且 GC 不能管理 GCIH 内部的 Java 对象,以此达到降低 GC 的回收频率和提升 GC 的回收效率的目的。
逃逸分析
- 如何将堆上的对象分配到栈,需要使用逃逸分析手段。
- 这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
- 通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
- 逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
public void my_method() {
V v = new V();
// use v
// ....
v = null;
} public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
} public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
/**
* 逃逸分析
*
* 如何快速的判断是否发生了逃逸分析,大家就看new的对象实体是否有可能在方法外被调用。
*/
public class EscapeAnalysis {
public EscapeAnalysis obj;
/*
方法返回EscapeAnalysis对象,发生逃逸
*/
public EscapeAnalysis getInstance(){
return obj == null? new EscapeAnalysis() : obj;
}
/*
为成员属性赋值,发生逃逸
*/
public void setObj(){
this.obj = new EscapeAnalysis();
}
//思考:如果当前的obj引用声明为static的?仍然会发生逃逸。
/*
对象的作用域仅在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis(){
EscapeAnalysis e = new EscapeAnalysis();
}
/*
引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis1(){
EscapeAnalysis e = getInstance();
//getInstance().xxx()同样会发生逃逸
}
}
-
在 JDK 1.7 版本之后,HotSpot 中默认就已经开启了逃逸分析
-
如果使用的是较早的版本,开发人员则可以通过:
- 选项
-XX:+DoEscapeAnalysis显式开启逃逸分析 - 通过选项
-XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果
- 选项
代码优化
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配
- 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
栈上分配
- 检查代码中的对象是否有可能逃逸出当前方法的作用域。如果对象在方法结束前不会逃逸,那么可以考虑将其分配在栈上。
- 如果 Java 虚拟机决定对对象进行栈上分配,它会在当前线程的栈帧中为对象分配内存。这个过程通常比在堆空间中分配内存要快,因为不需要进行垃圾收集或者对象定位等操作。
- 对象的构造方法被调用来初始化内存,并且对象引用被设置为指向新分配的栈空间。此时,对象已经被完全初始化并可用于程序中的其他部分。
- 当方法执行结束时,所有在当前线程的栈帧中分配的对象都会自动被销毁。这是因为栈帧的生命周期只存在于方法的执行期间,一旦方法返回,栈帧就会被销毁,其中包括在栈上分配的对象。
/**
* 栈上分配测试
* -Xmx128m -Xms128m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
*/
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
// 查看执行时间
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
// 为了方便查看堆内存中对象个数,线程sleep
try {
Thread.sleep(1000000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();//未发生逃逸
}
static class User {
}
} [GC (Allocation Failure) [PSYoungGen: 33280K->808K(38400K)] 33280K->816K(125952K), 0.0483350 secs] [Times: user=0.00 sys=0.00, real=0.06 secs]
[GC (Allocation Failure) [PSYoungGen: 34088K->808K(38400K)] 34096K->816K(125952K), 0.0008411 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 34088K->792K(38400K)] 34096K->800K(125952K), 0.0008427 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 34072K->808K(38400K)] 34080K->816K(125952K), 0.0012223 secs] [Times: user=0.08 sys=0.00, real=0.00 secs]
花费的时间为: 114 ms -Xmx128m -Xms128m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails花费的时间为: 5 ms -Xmx128m -Xms128m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails同步省略(同步消除)
public class SynchronizedTest {
public void f() {
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
}
} public void f() {
Object hellis = new Object();
System.out.println(hellis);
} 0 new #2 <java/lang/Object>
3 dup
4 invokespecial #1 <java/lang/Object.<init>>
7 astore_1
8 aload_1
9 dup
10 astore_2
11 monitorenter
12 getstatic #3 <java/lang/System.out>
15 aload_1
16 invokevirtual #4 <java/io/PrintStream.println>
19 aload_2
20 monitorexit
21 goto 29 (+8)
24 astore_3
25 aload_2
26 monitorexit
27 aload_3
28 athrow
29 return 标量替换
-
标量(scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。
-
相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
-
在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
public static void main(String args[]) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}
class Point {
private int x;
private int y;
} private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
} - 可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。
- 那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。
- 标量替换为栈上分配提供了很好的基础。
-XX:+ElimilnateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。/**
* 标量替换测试
* -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
* @author shkstart shkstart@126.com
* @create 2020 12:01
*/
public class ScalarReplace {
public static class User {
public int id;
public String name;
}
public static void alloc() {
User u = new User();//未发生逃逸
u.id = 5;
u.name = "www.atguigu.com";
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
}
} -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations[GC (Allocation Failure) 25600K->880K(98304K), 0.0012658 secs]
[GC (Allocation Failure) 26480K->832K(98304K), 0.0012124 secs]
[GC (Allocation Failure) 26432K->784K(98304K), 0.0009719 secs]
[GC (Allocation Failure) 26384K->832K(98304K), 0.0009071 secs]
[GC (Allocation Failure) 26432K->768K(98304K), 0.0010643 secs]
[GC (Allocation Failure) 26368K->824K(101376K), 0.0012354 secs]
[GC (Allocation Failure) 32568K->712K(100864K), 0.0011291 secs]
[GC (Allocation Failure) 32456K->712K(100864K), 0.0006368 secs]
花费的时间为: 99 ms -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations花费的时间为: 6 ms -server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations- 参数
-server:启动Server模式,因为在server模式下,才可以启用逃逸分析。 - 参数
-XX:+DoEscapeAnalysis:启用逃逸分析 - 参数
-Xmx10m:指定了堆空间最大为10MB - 参数
-XX:+PrintGC:将打印GC日志。 - 参数
-XX:+EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上,比如对象拥有 id 和 name 两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配
逃逸分析的不足
- 注意到有一些观点,认为通过逃逸分析,JVM 会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于 JVM 设计者的选择。据我所知,Oracle Hotspot JVM 中并未这么做(刚刚演示的效果,是因为HotSpot实现了标量替换),这一点在逃逸分析相关的文档里已经说明,所以可以明确在 HotSpot 虚拟机上,所有的对象实例都是创建在堆上。
- 目前很多书籍还是基于 JDK7 以前的版本,JDK 已经发生了很大变化,intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是 intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。
堆是分配对象的唯一选择么?
小结
-
年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
-
老年代放置长生命周期的对象,通常都是从 Survivor 区域筛选拷贝过来的 Java 对象。
-
当然,也有特殊情况,我们知道普通的对象可能会被分配在 TLAB 上;
-
如果对象较大,无法分配在 TLAB 上,则JVM会试图直接分配在 Eden 其他位置上;
-
如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM 就会直接分配到老年代。
-
当 GC 只发生在年轻代中,回收年轻代对象的行为被称为 Minor GC。
-
当 GC 发生在老年代时则被称为 Major GC 或者 Full GC。
-
一般的,Minor GC 的发生频率要比 Major GC 高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。
作者: Suwian 发表日期:2023 年 2 月 13 日