文章目录
人生得意须尽欢,莫使金樽空对月。
1.概述
- 在一个Java进程中,JVM实例中堆是唯一的,是所有线程共享的,JVM规范规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
- 堆区在JVM启动时即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间,且堆内存的大小可以通过参数调节
- 所有的线程共享Java堆,如果多个线程对堆中数据同时进行操作,那么堆就需要对数据进行加锁处理,这样会使堆的并发效率降低,为此,JVM提出一种技术:TLAB(Thread Local Allocation Buffer)线程私有缓冲区,在堆中给每一个线程划分一部分空间,就可以大幅度提升多线程并发的效率
- Java虚拟机提出:所有的对象实例以及数组都应该在运行时分配到堆上。但是在JVM的实现上,并不是所有的对象实例都在堆上分配内存,存在一部分数据在栈上分配,
但是这里的对象实际上是发生了标量替换
,所有我们可以认为,对象都在堆中分配。数组和对象可能永远不会存储在栈上,因为栈帧中的局部变量表理应保存对象的引用,这个引用指向了对象或数组在堆中的位置 - 在方法结束后,当前栈帧出栈后,堆中被引用的对象不会立马被移除,这些对象仅在垃圾回收过程中才会被移除,垃圾回收是不能频繁执行的,频繁的进行垃圾回收会导致用户线程的执行效率降低
- 堆是GC(垃圾回收器 Grabage Collection)执行垃圾回收的重点区域
给出一个大概的堆分配例子:
public class TestHeap {
private int shang;
public TestHeap(int shang) {
this.shang = shang;
}
public static void main(String[] args) {
TestHeap testHeap1 = new TestHeap(1);
TestHeap testHeap2 = new TestHeap(1);
}
}
//对main方法进行反编译,得到以下的字节码指令
/*
0 new #3 <com/shang/jvm/runtimedata/heap/TestHeap>
new指令在java堆上为TestHeap对象分配内存空间,并将地址压入操作数栈顶;
3 dup 复制操作数栈顶值,并将其压入栈顶,此时操作数栈上有连续相同的两个对象地址;
4 iconst_1 将数字1入栈
5 invokespecial #4 <com/shang/jvm/runtimedata/heap/TestHeap.<init> : (I)V>
注意这个方法是一个实例方法,所以需要从操作数栈顶弹出一个this引用和数字1,
也就是说这一步会弹出一个之前入栈的对象地址;
8 astore_1 对象地址赋值给index为1的变量
9 new #3 <com/shang/jvm/runtimedata/heap/TestHeap>
12 dup
13 iconst_2
14 invokespecial #4 <com/shang/jvm/runtimedata/heap/TestHeap.<init> : (I)V>
17 astore_2
18 return
*/
2.堆的细分
现代GC大部分都基于分代收集理论,堆空间由此被细分为:
- Java7之前:堆在逻辑上分为:年轻代+老年代+永久代
- 年轻代:Young Generation Space(young),又被划分为 Eden区和Survivor区
- 老年代 Tenure Generation Space(old)
- 永久区 Permanent Space(perm)
- Java8及之后:堆在逻辑上分为:年轻代+老年代+元空间
- 年轻代:Young Generation Space(young),又被划分为 Eden区和Survivor区
- 老年代 Tenure Generation Space(old)
- 元空间 Meta Space(meta)
永久代和元空间虽然在逻辑上是堆中的一部分,但是实际上是方法区的不同落地实现,方法区本质上是一个逻辑上的概念,堆本质上还是年轻代+老年代
3.设置堆的大小和OOM
默认情况下(堆的大小 = 年轻代 + 老年代):
- 初始内存大小:物理电脑内存大小 / 64
- 最大内存大小:物理电脑内存大小 / 4
在JVM启动前,可以通过设置参数来指定堆的大小,使用 -Xms和-Xmx
来设置
-Xms 用来设置堆空间(年轻代 + 老年代)的初始内存大小, 等价于 -XX:InitialHeapSzie
-X 是jvm的运行参数
ms 是memory start起始内存
-Xmx 用来设置堆空间(年轻代 + 老年代)的最大内存大小,等价于 -XX:MaxHeapSize
手动设置:-Xms 600M -Xmx 600M,开发中建议初始内存和最大内存设置为一样,其目的为了GC执行垃圾回收时不需要重新分隔计算堆区的大小,从而提高性能
当前堆空间大小一旦超出了-Xmx所指定的最大内存时,将会抛出OutOfMemoryError
jdk下bin目录中的Java VisualVM来查看JVM包括堆的当前状况
4.年轻代和老年代
存储在JVM的对象大致可以分为两类,一类是生命周期较短的瞬时对象,这些对象的创建和消亡都非常迅速,另一类对象生命周期较长,在某些极端情况下甚至和JVM的生命周期一致。
JVM堆进一步细分,可以分为年轻代和老年代,其中年轻代又可以分为Eden空间,Survivor0空间,Survivor1空间(有时候也叫from区、to区)
堆的总体大小可以通过-Xms -Xmx来设置,年轻代和老年代的占比也可以进行设置,使用 -XX: NewRetion=n 设置,n是老年代/年轻代
默认-XX:NewRation=2,表示年轻代占堆的1/3,老年代占堆的2/3
可设置-XX:NewRation=4,表示年轻代占堆的1/5,老年代占堆的4/5
一般情况不会更改堆内年轻代和老年代所占空间比例
在HotSpot中堆内的年轻代中,Eden空间和另外两个Survivor空间所占比例默认是8:1:1
几乎所有的对象都是在Eden空间被new出来的,绝大多数对象的回收也在年轻代进行,约80%的对象都是“朝生夕死”的
为什么需要把JVM中的堆分代?
- 经过研究,不同对象的生命周期不同,70%-99%的对象是临时对象。分代唯一的理由就是优化GC性能,如果没有分代,那所有的对象都被放在一起
- 垃圾回收时要找到哪些对象没有时,就需要对堆的所有区域进行扫描
- 如果分代的话,就可以把生命周期短的对象和生命周期长的对象分开存储,分开进行垃圾回收能极大提高垃圾回收的效率
5. 为对象分配内存的一般过程
为新对象分配内存是一项非常严谨和复杂的任务,JVM的设计者不仅需要考虑如何内存如何分配,在哪分配等问题,且由于内存分配和垃圾回收算法密切相关,所以还需要考虑GC执行完毕后是否会在内存空间中产生内存碎片
下面对对象内存分配作出演示。
如上图所示:
- 需要
new
的对象先在Eden区
中被创建 - 当
Eden区
满后,进行Minor GC
,此时用户线程停止,Minor GC
将垃圾(不在被引用的对象)回收,将剩余幸存的对象移入s0
- 每个对象都有一个
年龄计数器
,此时幸存对象年龄为1 - 此时
Eden清空
,S0
内有对象,S1
为空, - 用户线程继续将new的对象放入
Eden区
中。
对上图分析:
- 当
Eden区
满后,再次进行Minor GC
,将Eden区
和S0
区的垃圾回收 - 将
S0
中还幸存的对象放入S1
,且年龄+1 - 将
Eden
中幸存的对象放入S1
,年龄为1 - 此时
Eden清空
,S0清空
,S1内存放对象
,用户线程继续将new的对象放入Eden区
中。
由上面的两个图可以看出,S0
和S1
就是两个手,每次幸存的对象在这两者之间总是左手倒右手,总有一个S为空,一个S保存年轻代幸存的对象。
对上图分析:
- 当
Eden区
再次满后,再进行Minor GC
- 将
Eden区
和S0
区的垃圾回收 - 将
S1
中幸存的且年龄大于15的对象直接放入老年代,年龄+1 - 将
S1
中还幸存的对象放入S0
,且年龄+1,将Eden
中幸存的对象放入S0
,年龄为1 - 此时
Eden清空
,S0内有对象
,S1清空
,老年代也存放了年龄大于15的对象
这里的15是年龄计数器的默认阈值,该阈值可通过参数进行设置,
-XX:MaxTenuringThreshold=<N>
在年轻代中年龄超过阈值的,对象需要被放到老年代里
小结:
-
Minor GC只会在Eden满了后触发,即使S满了也不会触发
Minor GC将对整个年轻代(Eden,非空的S)进行垃圾回收 -
S0和S1总有一个为空
-
堆内垃圾回收的过程频繁在年轻代进行,很少在老年代进行
6. 为对象分配内存的特殊过程
上述为对象分配内存是普适情况,但仍有一些特殊情况:
比如需要创建的对象比整个Eden还大,比如Minor GC开始后,非空的S已满,Eden幸存的对象放不进去等
两种特殊情况处理:
- 需要创建的对象比整个Eden还大:
- 先进行
YGC
- 如果
YGC
后Eden
还放不下,尝试放入old
中 - 如果
old
放不下,进行一次full GC 或 Major GC
- 如何
old
可以放下,分配至old
,否则,报OOM
- 先进行
- Minor GC结束后,非空的S已满,Eden幸存的对象放不进去
- 将
Eden
中幸存的对象放到Old
中
- 将
7. Minor GC、Major GC和Full GC
JVM在进行GC时,并非每次都对三个区(新生代、老年代、方法区)一起进行回收,大部分回收的都是新生代
.
对于HotSpot VM
而言,GC按照回收区域分为部分收集(Partial GC)
和整堆收集(Full GC)
部分收集:对堆中部分区域进行垃圾回收,具体分为:
-
Minor GC 年轻代垃圾收集器 频率最高
-
Major GC 老年代垃圾收集器
整堆收集:收集整个Java堆和方法区的垃圾
Full GC 堆及方法区(永久代/元空间)垃圾收集器
Minor GC的触发机制:
当Eden空间不足时,就会触发Minor GC,S满了不会触发Minor GC
每次Minor GC都会清理整个年轻代(Eden+S)的垃圾。
因为Java对象大多数生命周期较短,所以Minor GC非常频繁。一般回收速度也比较快,Minor GC会引发STW(stop the world)即停止用户线程。等Minor GC结束后,用户线程才恢复运行(收集垃圾的时候停止制造垃圾)
Major GC的触发机制:
- 出现了Major GC经常会伴随至少一次的Minor GC
- 也就是老年代空间不足时,会先触发Minor GC,如果之后空间还是不足则触发Major GC
Major GC的速度比Minor GC慢10倍以上,STW的时间更长.如果Major GC后内存还是不足,就会出现OOM
Full GC的触发机制:
触发Full GC执行的情况有以下几种:
1,调用System.gc()时,系统建议执行Full GC,但不一定执行
2,老年代空间不足
3,方法区空间不足
4,通过Minor GC后进入老年代的平均大小大于老年代的可用内存Full GC是开发或调优中要尽量避免的,这样STW的时间会短一些
很多时候Major GC和Full GC会混淆使用,需要具体分辨是老年代垃圾回收,还是整堆垃圾回收
注:GC也是一个线程,GC的执行过程中,需要暂停用户线程,所以GC不应该过于频繁
8. 堆空间为每个线程分配的TLAB
堆区是线程的共享区域,任何线程都可以访问堆区中的共享数据
由于对象的创建过程在JVM中非常频繁,因此在并发环境下从堆中划分内存空间是线程不安全的,如线程A和线程B此时同时在执行,它们都想要在堆中创建对象,但是此时它们选择了堆中相同的地址,此时就出现了问题,为了避免多线程操作堆中同一个地址,需要使用加锁等机制,但加锁又必定降低效率
为此就有了TLAB(Thread Local Allocation Buffer),堆中线程私有的缓冲区,在Eden中为每个线程分配了一个私有的缓冲区,即TLAB
多线程同时分配内存时,使用TLAB可以避免一系列的线程安全问题,同时可以提示内存分配的吞吐量,几乎所有的JVM都提供了TLAB’的设计
以下是TLAB的分配过程
几个补充点:
- 可以通过配置参数 -XX:UseTLAB 设置开启TLAB空间
- 默认情况下,每个TLAB空间的内存非常小,仅占整个Eden的1%,可以通过参数 -XX:TLABWasteTargetPercent设置TLAB空间所占Eden空间的百分比大小
- 不是所有对象都能在TLAB中分配成功,但JVM将TLAB作为内存分配的首选
- 一旦对象在TLAB空间分配内存失败时,JVM会通过加锁机制,来确保数据操作的原子性,从而在Eden中分配内存
9. 堆中的常用参数小结
1,-XX:+PrintFlagsInitial 查看所有参数的默认初始值
2,-XX:+PrintFlagsFinal 查看所有参数的最终值
3,-Xms 设置堆起始大小
4,-Xmx 设置堆最大空间
5,-Xmn 设置年轻代的大小
6,-XX:NewRatio 配置年轻代与老年代在堆结构的占比
7,-XX:SurvivorRatio 设置年轻代中Eden和S0/S1空间的比例
8,-XX:MaxTenuringThreshold 设置年轻代中垃圾的最大年龄
9,-XX:HandlePromotionFailure 是否设置空间分配担保
10.堆是否是分配对象的唯一选择?
在JVM中,对象是在堆中分配内存创建的,这是一个普遍的常识,但是有一种特殊情况,如果经过“逃逸分析”后发现,一个对象并没有逃逸出方法的话,那么可能被优化成栈上分配,也就是说有些对象并没有在堆中创建。
栈上分配,就是对该对象进行逃逸分析,如果未发生逃逸,通过标量替换,将该对象打散成若干局部变量存储在栈帧中的局部变量表中,这样就可以减少在堆中对象的创建,间接减少了GC。
10.1 逃逸分析 Escape Analysis
将对象分配到栈上,需要使用逃逸分析手段,这是一种可以有效减少Java程序中同步负载和内存分配压力的跨函数全局数据流分析算法
逃逸分析的基本行为就是分析对象的作用域:
-
当一个对象在方法中被定义后,对象只在
方法内使用
则认为没有发生逃逸,就可以使用栈上分配
,该对象将被创建在虚拟机栈
上,而非堆中,间接减少了GC的次数。且该对象从属的方法对应的栈帧出栈后,栈帧被销毁,该对象也会自动销毁 -
当一个对象在方法中被定义后,它被
外部方法所引用
,如作为参数传递到了其它方法中,则认为发生了逃逸
通过逃逸分析,HotSpot VM的编译器能分析出一个新对象的引用范围
从而决定是否将这个对象分配到堆上.
public class EscapeAnalysis {
//外部方法引用了stringBuffer,发生逃逸
public StringBuffer getStringBuffer(int x, int y) {
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(x);
stringBuffer.append(y);
return stringBuffer;
}
//外部方法没有引用stringBuffer,未发生逃逸
public String getString(int x, int y) {
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(x);
stringBuffer.append(y);
return stringBuffer.toString();
}
}
判断一个new的对象是否发生了逃逸,看它有没有被外部的方法使用
,如果没有被外部方法使用,则没有发生逃逸,可以在栈上分配;该对象被外部方法使用,则发生了逃逸,需要在堆中分配
在HotSpot VM中可以使用参数来查看关于逃逸分析的数据
-XX: +DoEscapeAnalysis 显示开启逃逸分析 默认是开启的
-XX: +PrintEscapeAnalysis 查看逃逸分析的筛选结果
10.2 编译器通过逃逸分析来优化代码
开启逃逸分析后,编译器可以对代码做一些优化:
栈上分配
:将堆分配转换为栈上分配。如果一个对象在子程序中被分配,如果该对象永远不会发生逃逸,对象可能是栈分配的候选,而不是堆分配同步省略
:一个对象被发现只能被一个对象访问到,那么对于这个对象的操作可以不考虑同步分类对象或标量替换
:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
10.2.1 测试栈上分配
不开启逃逸分析
/**
* 测试栈上分配
* 参数设置
* 不启用逃逸分析 : -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
* @Author :漠殇
* @Data :Create in 18:20 2022/6/6
*/
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
callOc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间: " + (end - start) + "ms");
try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private static void callOc() {
User user = new User(); //user未发生逃逸
}
static class User {
}
}
开启逃逸分析
启用逃逸分析 : -Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
从上面的程序得知,创建了一百万个USer对象,经过逃逸分析,确定它们都没有逃逸,为此这些对象大都被分配在栈帧中,栈上分配创建这一百万个对象花费了5ms,如果关闭逃逸分析,会需要数倍的时间,且堆中GC的次数也会很频繁,导致多次的STW
10.2.2 测试同步省略
线程同步的代价是相当高的,同步的后果是降低并发性和性能
如果一个对象被发现到只能被一个线程访问到,那么对这个对象的操作可以不考虑同步,即忽略它的同步块
在动态编译同步块时,JIT编译器可以借助逃逸分析来判断同步块所使用的对象锁是否只能被一个线程访问而没有被发布到其它线程。如果没有,那么JIT编译器在编译这个同步块时就会取消对这部分代码的同步,这样就能大大提高并发性和性能,这个过程就是同步省略,也叫锁消除
public class SysDel {
// jIT经过逃逸分析,发现testHeap只被一个线程使用,所以JIT就会将同步去掉,变为下面的代码
public void f() {
TestHeap testHeap = new TestHeap();
synchronized (testHeap) {
testHeap.hashCode();
}
}
// 经过JIT逃逸分析后,去掉了同步代码块
public void f1() {
TestHeap testHeap = new TestHeap();
testHeap.hashCode();
}
}
10.2.3 测试标量替换
标量替换
是指将对象拆解成若干基本类型
, 即不创建该对象,创建一堆局部变量来代替该对象
标量指一个无法再分解成更小数据的数据,如Java中的基本数据类型 可以被分解的数据叫聚合量,Java中的对象就是聚合量.
测试程序:
/**
* 测试标量替换
* 不启用逃逸分析 : -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
* 启用逃逸分析 : -Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
* @Author :漠殇
* @Data :Create in 18:43 2022/6/6
*/
public class Replace {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
f();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间: " + (end - start) + "ms");
try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void f() {
Point point = new Point(1, 2);
//System.out.println("x : " + point.x + " , y : " + point.y);
}
static class Point {
int x;
int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
}
不开启逃逸分析
开启逃逸分析
通过标量替换,可以避免某些对象的创建过程,将局部变量放到栈帧中的局部变量表中,避免在堆中创建对象,标量替换就是栈上分配的基础,标量替换默认是开启的
10.3 堆是分配对象的唯一选择(HotSpot VM)
总的来说,在HotSpot VM中,堆仍是分配对象的唯一选择,所谓的栈上分配,其实并没有在栈上创建对象,只是将未逃逸的对象通过标量替换将其肢解成局部变量放到了栈帧中的局部变量表中,真正意义上的对象仍然存储在堆中