本篇文章的主要内容是介绍JVM内存的分配方式、JVM内存的快速分配策略、JVM的逃逸分析和堆内存的分代思想几部分内容。
请同学们认真听讲,面试会问到。。。
1. 内存分配
大家需要注意不分配内存的对象无法进行其他操作
JVM 为对象分配内存的过程:首先计算对象占用空间大小,接着在堆中划分一块内存给新对象
主要方式分为以下两种:
- 如果内存规整,使用指针碰撞(Bump The Pointer)。所有用过的内存在一边,空闲的内存在另外一边,中间有一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离
- 如果内存不规整,虚拟机需要维护一个空闲列表(Free List)分配。已使用的内存和未使用的内存相互交错,虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容
2. TLAB(线程本地分配缓冲区)
TLAB:Thread Local Allocation Buffer 线程本地分配缓冲区,为每个线程在堆内单独分配了一个缓冲区,多线程分配内存时,使用 TLAB 可以避免线程安全问题,同时还能够提升内存分配的吞吐量,这种内存分配方式叫做快速分配策略
- 栈上分配使用的是栈来进行对象内存的分配(本身就是线程安全的)
- TLAB 分配使用的是 Eden 区域进行内存分配,属于堆内存
堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据,由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
下面来关注一个问题:
面试官:堆空间都是共享的么?
你应该这么回答:其实不一定,因为还有 TLAB区域,在堆中划分出一块这么一块区域,为每个线程所独占
(图片来源:https://github.com/Seazean/JavaNote)
JVM 是将 TLAB 作为内存分配的首选,但不是所有的对象实例都能够在 TLAB 中成功分配内存,一旦对象在 TLAB 空间分配内存失败时,JVM 就会通过使用加锁机制确保数据操作的原子性,从而直接在堆中分配内存
栈上分配优先于 TLAB 分配进行,逃逸分析中若可进行栈上分配优化,会优先进行对象栈上直接分配内存
参数设置:
-
-XX:UseTLAB
:设置是否开启 TLAB 空间 -
-XX:TLABWasteTargetPercent
:设置 TLAB 空间所占用 Eden 空间的百分比大小,默认情况下 TLAB 空间的内存非常小,仅占有整个 Eden 空间的1% -
-XX:TLABRefillWasteFraction
:指当 TLAB 空间不足,请求分配的对象内存大小超过此阈值时不会进行 TLAB 分配,直接进行堆内存分配,否则还是会优先进行 TLAB 分配
下面通过一个图来更直观的看下分配过程:
(图片来源:https://github.com/Seazean/JavaNote)
3. 逃逸分析
即时编译(Just-in-time Compilation,JIT)是一种通过在运行时将字节码翻译为机器码,从而改善性能的技术,在 HotSpot 实现中有多种选择:C1、C2 和 C1+C2,分别对应 Client、Server 和分层编译
- C1 编译速度快,优化方式比较保守;C2 编译速度慢,优化方式比较激进
- C1+C2 在开始阶段采用 C1 编译,当代码运行到一定热度之后采用 C2 重新编译
逃逸分析并不是直接的优化手段,而是一个代码分析方式,通过动态分析对象的作用域,为优化手段如栈上分配、标量替换和同步消除等提供依据,发生逃逸行为的情况有两种:方法逃逸和线程逃逸
- 方法逃逸:当一个对象在方法中定义之后,被外部方法引用
- 全局逃逸:一个对象的作用范围逃出了当前方法或者当前线程,比如对象是一个静态变量、全局变量赋值、已经发生逃逸的对象、作为当前方法的返回值
- 参数逃逸:一个对象被作为方法参数传递或者被参数引用
- 线程逃逸:如类变量或实例变量,可能被其它线程访问到
如果不存在逃逸行为,则可以对该对象进行如下优化:同步消除、标量替换和栈上分配
-
同步消除
线程同步本身比较耗时,如果确定一个对象不会逃逸出线程,不被其它线程访问到,那对象的读写就不会存在竞争,则可以消除对该对象的同步锁,通过
-XX:+EliminateLocks
可以开启同步消除 ( - 号关闭) -
标量替换
-
标量替换:如果把一个对象拆散,将其成员变量恢复到基本类型来访问
-
标量 (scalar) :不可分割的量,如基本数据类型和 reference 类型
聚合量 (Aggregate):一个数据可以继续分解,对象一般是聚合量
-
如果逃逸分析发现一个对象不会被外部访问,并且该对象可以被拆散,那么经过优化之后,并不直接生成该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替
-
参数设置:
-XX:+EliminateAllocations
:开启标量替换-XX:+PrintEliminateAllocations
:查看标量替换情况
-
-
栈上分配
JIT 编译器在编译期间根据逃逸分析的结果,如果一个对象没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收,这样就无需 GC
User 对象的作用域局限在方法 fn 中,可以使用标量替换的优化手段在栈上分配对象的成员变量,这样就不会生成 User 对象,大大减轻 GC 的压力
public class JVM { public static void main(String[] args) throws Exception { int sum = 0; int count = 1000000; //warm up for (int i = 0; i < count ; i++) { sum += fn(i); } System.out.println(sum); System.in.read(); } private static int fn(int age) { User user = new User(age); int i = user.getAge(); return i; } } class User { private final int age; public User(int age) { this.age = age; } public int getAge() { return age; } }
4. 分代思想
JVM堆内存的分代思想是基于对象生命周期的不同来划分堆内存区域,以便更高效地管理内存和进行垃圾回收。在JDK 1.8中,JVM堆内存主要被划分为新生代(Young Generation)和年老代(Old Generation)两大部分。
新生代主要用于存放新创建的对象,这些对象大多数生命周期较短,很快就会被回收。新生代又被进一步细分为Eden区(Eden Space)和两个Survivor区(Survivor Space,通常称为S0和S1)
年老代则主要用于存放生命周期较长的对象。这些对象通常是在应用程序运行期间持续存在的,因此不需要频繁地进行垃圾回收。
新生代主要使用复制算法,老年代主要使用标记 - 清除 或者 标记 - 整理 算法
Minor GC 和 Full GC介绍:
- Minor GC:回收新生代,新生代对象存活时间很短,所以 Minor GC 会频繁执行,执行的速度比较快
- Full GC:回收老年代和新生代,老年代对象其存活时间长,所以 Full GC 很少执行,执行速度会比 Minor GC 慢很多
Eden 和 Survivor 大小比例默认为 8:1:1
分代分配的工作机制:
- 对象优先在 Eden 分配:当创建一个对象的时候,对象会被分配在新生代的 Eden 区,当 Eden 区要满了时候,触发 YoungGC
- 当进行 YoungGC 后,此时在 Eden 区存活的对象被移动到 to 区,并且当前对象的年龄会加 1,清空 Eden 区
- 当再一次触发 YoungGC 的时候,会把 Eden 区中存活下来的对象和 to 中的对象,移动到 from 区中,这些对象的年龄会加 1,清空 Eden 区和 to 区
- To 区永远是空 Survivor 区,From 区是有数据的,每次 MinorGC 后两个区域互换
- From 区和 To 区 也可以叫做 S0 区和 S1 区
如何晋升到老年代:
-
长期存活的对象进入老年代:为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中
-XX:MaxTenuringThreshold
:定义年龄的阈值,对象头中用 4 个 bit 存储,所以最大值是 15,默认也是 15 -
大对象直接进入老年代:需要连续内存空间的对象,最典型的大对象是很长的字符串以及数组;避免在 Eden 和 Survivor 之间的大量复制;经常出现大对象会提前触发 GC 以获取足够的连续空间分配给大对象
-XX:PretenureSizeThreshold
:大于此值的对象直接在老年代分配 -
动态对象年龄判定:如果在 Survivor 区中相同年龄的对象的所有大小之和超过 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代
空间分配担保:
- 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的
- 如果不成立,虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于将尝试着进行一次 Minor GC;如果小于或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC
本篇文章到这里就结束了,最后送大家一句话 白驹过隙,沧海桑田
合作交流:mdx_0422
获取2024大厂面试资料、学习工具、微服务电商项目源码,加入java学习小组的同学可以关注下方公众号~