面试官问我:你确定JVM堆内存是共享的?
共 5306字,需浏览 11分钟
·
2021-04-12 21:15
这应该是大鱼写的RocketMQ的第5篇文章了,之前四篇分别是
我们在之前一篇关于JVM内存结构中,介绍了两个比较常见的区域是堆内存和栈内存,堆和栈的区别,大家应该也听得耳朵都出茧子了
堆是线程共享的内存区域,栈是线程独享的内存区域;堆中主要存放的是对象实例,栈中存放的是各种基本数据类型和对象的引用
但是呢,大鱼前几天去面试,面试官也问了我这个问题,而且没有就此罢休,而是问了我很多平时遇不到的问题,不过好在大鱼我曾经在看某一技术博主的文章的时候,跟着多学习了下
Java堆的区域都是线程共享的吗?
当你听到这个问题的时候,你首先想到的是什么呢?
let me tell you
面试官其实问这个的时候就是在看你对堆的了解程度,你只知道是用来放对象实例的,那面试官对你表现觉得不算非常满意;但是如果你知道TLAB,并且知道它的原理和问题,那面试官就会觉得:这小伙子不一般,我得再多深入了解了解,可以考虑当我的好助手
首先,你得肯定回答,没错,堆是全局共享的,但是会存在一些问题
就是多个线程在堆上同时申请空间,如果在并发的场景中,两个线程先后把对象引用指向了同一个内存区域,那可能就会出现问题;为了解决这个问题呢,就得进行同步控制,说到同步控制,就会影响到效率
就拿Hotspot来举例子,它的解决方案是每个线程在堆中都预先分配一小块内存,然后再给对象分配内存的时候,先在这块“私有内存”进行分配,这块用完之后再去分配新的“私有内存”,这就是TLAB分配
你也看到了,我加引号了,它并不是真正意义上的私有,而是表面上的私有
它是从堆内存划分出来的,有了TLAB技术,堆内存并不是完完全全的线程共享,每个线程在初始化的时候都会去内存中申请一块TLAB
切记:并不是TLAB区域的内存其它线程完全无法访问,其它线程也是可以读取的,只不过无法在这个区域分配内存而已
说到这的时候,也给面试官一个眼神,说明我的干货还没完,我还能继续吹
难道TLAB很完美吗?所谓,金无足赤人无完人,肯定有他的问题所在
我们知道TLAB是线程特有的,它的内存区域不是很大,所以会出现一些不够用的情况,比如一个线程的TLAB的空间有100KB,其中已经使用了80KB,如果还需要再分配一个30KB的对象,则无法直接在TLAB上分配了,这种情况有两种解决办法
直接在堆中分配
废弃当前TLAB,重新申请TLAB空间再次进行内存分配
其实这两种方案各有利弊
第一种的缺点就是存在一种极端情况,TLAB只剩下1KB,就会导致后续的分配可能大多数对象都需要直接在堆中分配;第二种的就是可能会出现频繁的废弃TLAB、频繁申请TLAB的情况
为了解决这两个方案存在的问题,虚拟机定义了一个refill_waste的值,这个值可以翻译为“最大浪费空间”
当请求分配的内存大于refill_waste的时候,会选择在堆内存中分配。若小于refill_waste值,则会废弃当前TLAB,重新创建TLAB进行对象内存分配
那你刚刚说的,几乎所有对象实例都存储在这里,是还有例外吗?能详细解释下吗?
是的,亲爱的面试官,Java对象实例和数组元素不一定都是在堆上分配内存,满足特定的条件的时候,它们可以在栈上分配内存
面试官微微一笑,那这是什么情况呢?
亲爱的面试官,是这样子的,JVM中的Java JIT编译器有两个优化,叫做逃逸分析和标量替换;
逃逸分析,听着有点意思,逃,谁逃,什么时候逃,往哪里逃?
中文维基上对逃逸分析的描述挺准确的,摘录如下:
在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法——分析在程序的哪些地方可以访问到指针。当一个变量(或对象)在子程序中被分配时,一个指向变量的指针可能逃逸到其它执行线程中,或是返回到调用者子程序。
大鱼白话文版本:
一个子程序分配了一个对象并且返回了该对象的指针,那么这个对象在整个程序中被访问的地方无法确定,任何调用这个子程序的都可以拿到这个对象的位置,并且调用这个对象,遂,对象逃之;
若指针存储在全局变量或者其它数据结构中,全局变量也可以在子程序之外被访问到,遂,对象逃之;
若未逃之,则可将方法变量和对象分配到栈上,方法执行完之后自动销毁,不需要垃圾回收的介入,提高系统的性能
简洁版:
逃逸分析通过分析对象引用的作用域,来决定对象的分配地方(堆 or 栈)
我们一起来看个例子
public StringBuilder getBuilder1(String a, String b) {
StringBuilder builder = new StringBuilder(a);
builder.append(b);
// builder通过方法返回值逃逸到外部
return builder;
}
public String getBuilder2(String a, String b) {
StringBuilder builder = new StringBuilder(a);
builder.append(b);
// builder范围维持在方法内部,未逃逸
return builder.toString();
}
getBuilder1中的builder对象会通过方法返回值逃逸到方法的外部,而反观getBuilder2中的builder对象则不会溢出去,作用域只会在方法内部,toString方法会new一个String用来返回,所以没有逃逸
如果把堆内存限制得小一点(比如加上-Xms10m -Xmx10m),关闭逃逸分析还会造成频繁的GC,开启逃逸分析就没有这种情况,说明逃逸分析确实降低了堆内存的压力
逃逸分析了之后,就可以直接降低堆内存的压力吗?(你刚刚说的那个标量替换是什么)
但是,逃逸分析只是栈上内存分配的前提,接下来还需要进行标量替换才能真正实现。标量替换用话不太好说明,直接来看例子吧,形象生动
public static void main(String[] args) throws Exception {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
allocate();
}
System.out.println((System.currentTimeMillis() - start) + <span data-raw-text="" "="" data-textnode-index="156" data-index="3462" class="character">" ms<span data-raw-text="" "="" data-textnode-index="156" data-index="3466" class="character">");
Thread.sleep(10000);
}
public static void allocate() {
MyObject myObject = new MyObject(2019, 2019.0);
}
public static class MyObject {
int a;
double b;
MyObject(int a, double b) {
this.a = a;
this.b = b;
}
}
标量,就是指JVM中无法再细分的数据,比如int、long、reference等。相对地,能够再细分的数据叫做聚合量
Java虚拟机中的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它称为聚合量,Java中最典型的聚合量是对象
如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。
拆散后的变量便可以被单独分析与优化,可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了
仍然考虑上面的例子,MyObject就是一个聚合量,因为它由两个标量a、b组成。通过逃逸分析,JVM会发现myObject没有逃逸出allocate()方法的作用域,标量替换过程就会将myObject直接拆解成a和b,也就是变成了:
static void allocate() {
int a = 2019;
double b = 2019.0;
}
可见,对象的分配完全被消灭了,而int、double都是基本数据类型,直接在栈上分配就可以了。所以,在对象不逃逸出作用域并且能够分解为纯标量表示时,对象就可以在栈上分配
除了这些之后,你还知道哪些优化吗?
emmm,先思索一下(即使知道,也要稍加思考!
除此之外,JVM还有一个同步消除(锁消除):锁消除是Java虚拟机在JIT编译是,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。
锁消除基于分析逃逸基础之上,开启锁消除必须开启逃逸分析
线程同步本身比较耗,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,对这个变量的同步措施就可以消除掉。单线程中是没有锁竞争。(锁和锁块内的对象不会逃逸出线程就可以把这个同步块取消)
public synchronized String append(String str1, String str2) {
StringBuffer sBuf = new StringBuffer();
// append方法是同步操作
sBuf.append(str1);
sBuf.append(str2);
return sBuf.toString();
}
从源码中可以看出,append方法用了synchronized关键词,它是线程安全的。但我们可能仅在线程内部把StringBuffer当作局部变量使用
这时我们可以通过编译器将其优化,将锁消除,前提是java必须运行在server模式,server模式会比client模式作更多的优化,同时必须开启逃逸分析
说一说刚刚说的这些的参数吗
我个乖乖兔,这我哪记得,不过得亏我昨天刚读了大鱼的文章,顺便学习了下
逃逸分析:-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启,其它版本未测试);-XX:-DoEscapeAnalysis 关闭逃逸分析
同步消除:-XX:+EliminateLocks开启锁消除(jdk1.8默认开启,其它版本未测试);-XX:-EliminateLocks 关闭锁消除
标量替换:-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启,其它版本未测试);-XX:-EliminateAllocations 关闭标量替换
好了,大鱼,今天面试暂时先到这里,明天下午继续来这里二面吧
好的,亲爱的面试官,今天和您聊得也很开心,我也收获颇多
我家里的小米粥也熬好了快,我就先拜拜了~
好了,以上就是全部内容了,我是小鱼仙,你们的学习成长小伙伴
我希望有一天能够靠写字养活自己,现在还在磨练,这个时间可能会有很多年,感谢你们做我最初的读者和传播者。请大家相信,只要给我一份爱,我终究会还你们一页情的。
再次感谢大家能够读到这里,我后面会持续的更新技术文章以及一些记录生活的灵魂文章,如果觉得不错的,觉得【大鱼同学】有点东西的话,求点赞、关注、分享三连
哦,对了!后续的更新文章我都会及时放到这里,欢迎大家点击观看,都是干货文章啊,建议收藏,以后随时翻阅查看
https://github.com/DayuMM2021/Java
● 消息队列入门