面试官问我:你确定JVM堆内存是共享的?

在这里插入图片描述

前言

这应该是大鱼写的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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值