① 堆的核心概念
- 一个
JVM
实例只存在一个堆内存,堆也是Java
内存管理的核心区域。 Java
堆区在JVM
启动时就被创建,其空间大小也确定下来(允许参数设置)。是JVM
管理的最大一块内存空间。<<Java虚拟机规范>>
规定,堆可以处于物理机上不连续的内存空间中,但逻辑上应该被视为连续的。- 虽然所有线程共享堆,但是堆空间中还有一部分区域被划分为了线程私有的缓冲区
(Thread Local Allocation Buffer,TLAB)
。这是为了提升并发效率而设计的。 - 如果说一个栈空间维护了一个堆中对象的引用,这个方法结束时,堆中对象不会马上被移除,而是在被
GC
识别为垃圾的时候才会被移除。 - 堆是
GC
执行垃圾回收的重点区域。
② 设置堆内存大小和OOM
Java
堆在JVM
启动时就已经设定好了,但在启动前可以通过-Xms和-Xmx
进行设置。
-Xms
设置堆的起始内存。-Xmx
设置堆的最大内存。一旦堆内存超过设置的最大内存时,就抛出OutOfMemoryError
异常。
在生产环境中,-Xms
和-Xmx
最好设置为同样大小,可以提升性能。
**单位:**当无后缀的时候表示byte
例如-Xms62291456
,k
表示千字节例如-Xms6144k
,m
表示兆例如-Xms6m
。
默认情况:
在不去人为设置堆空间大小的时候。
- 初始内存大小:物理机内存大小 /
64
。 - 最大内存大小:物理机内存大小 /
4
。
③ 新生区与养老区
存储在JVM
中的Java
对象可以被划分为两类:
- 生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速。
- 生命周期较长的对象,在某些极端情况下还能够与
JVM
的生命周期保持一致。
其中年轻代又可以划分为Eden
空间、Survivor0
空间和Suvivor1
空间。也可以成为from
区,to
区。
④ 图解对象分配过程
详细步骤:
红色表示需要被GC
的对象,绿色表示依旧存活的对象。
当年轻代满时触发MinorGC/YGC
,将不被需要的对象销毁,将继续使用的对象加入到S0或S1
区,假设加入到S0
区,则S1
区为空,此时S1
区称为to
区。****
当年轻代重新满时再次触发MinorGC/YGC
,将不被需要的对象销毁,将继续使用的对象加入到to
区。然后再检测from
区,将不再需要的对象销毁,将继续使用的对象移动至to
区。并且对移动的对象进行age + 1
操作。此时由于移动,from
区该销毁的销毁,该移动的移动。曾经的from
区为空,to
区不为空。此时曾经的from
区变为to
区,to
区变为from
区。
经过持续的GC
,to
区会出现一些对象的age
属性达到了某一阈值。这里假设阈值为15
,于是将达到阈值的对象加入到老年代。
自定义阈值:
-XX:MaxTenuringThreshold=<N>
⑤ Minor GC、Major Gc、Full GC
由于GC
线程会阻塞用户线程,所以在调优时,我们需要在保证安全的情况下尽可能减少GC
频率,减少阻塞时间。
JVM
在进行GC
时,并不是每次都对三个内存区域(新生代,老年代,方法区)
一起回收,而绝大多数回收的都是年轻代。
- 新生代收集:对新生代,
S0
,S1
区域进行收集,使用Minor GC/Yong GC
。 - 老年代收集:只对老年代,使用
Major GC/Old GC
。 - 整堆收集:对整个
Java
堆和方法区进行收集。使用Full GC
。
年轻代Minor GC
触发机制:
- 当年轻代空间不足时会触发
Minor GC
,Survivor0/1
满并不会引发GC
。但Minor GC
一旦触发也会清理S0/1
区域。 - 因为
Java
对象大多数都是转瞬即逝的,所以**Minor GC
的触发会非常频繁。** - **
Minor GC
会引发STW => Stop The World
,暂停其他用户线程,**等垃圾回收结束后,用户线程才会恢复。
老年代Major GC或Full GC
触发机制:
- **出现
Major GC
时,经常会伴随至少一次的Minor GC``(但并不是绝对的)
。**当老年代空间不足时,会先尝试触发Minor GC
。如果发现空间还不足,则触发Major GC
。 Major GC
的速度比Minor GC
慢10
倍以上。STW
时间更长。- 如果
Major GC
后内存还不足,则会OOM Java Heap Space
。
⑥ 堆空间分代思想
- 经研究,
Java
中的对象**70% ~ 99%
都是临时对象**。
为什么需要把Java
堆分代?不分代就不能正常工作吗?
其实不分代是完全可以的。但这么做是为了优化GC
性能。将多次没有被GC
的对象放到老年代,可以避免持久对象进行多次无用的GC
检查。
⑦ 内存分配策略
-
优先分配到
Eden
区域。 -
大对象由于
Eden
区域放不下,直接分配到老年代。(但是要尽量笔迷这样的情况,因为Major GC会消耗更多性能)
。 -
长期存活的对象分配到老年代。
-
动态年龄判断:如果
Survivor
区域中年龄相同的对象总和大于了Survivor
空间的一半,年龄大于等于该年龄的对象可以直接进入老年代,无需等到MaxTenuringThresh
中要求的age
。 -
空间分配担保:
-XX:HandlePromotionFailure
可以设定其参数。对于Survivor
区域的对象,无法进行Promotion
操作的时候,即Survivor
空间不足,则可以直接晋升到老年代。
⑧ 为对象分配内存:TLAB(Thread Local Allocation Buffer)
为什么有TLAB
?
- 堆是线程共享区域,任何线程都可以访问到堆区的共享数据。
- 由于共享,可能出现线程安全问题。
- 为了避免多个线程操作同一地址,需要使用加锁机制,但这样影响分配速度。
什么是TLAB
?
JVM
在Eden
区为每个线程都划分了一块线程独有的缓冲区,包含于Eden
区域内。
多线程同时分配内存时,使用TLAB
可以避免一些列的线程安全问题,同时还可以提升内存分配的吞吐量,这被称为快速分配策略。
TLAB
再说明:
- 尽管不是所有的对象实例都能够被分配到
TLAB
区域,但JVM
确实是将TLAB
作为内存的首选。 - 在程序中,可以通过
-XX:UseTLAB
设置是否开启TLAB
空间。 - 默认情况下,
TLAB
的区域空间非常小,仅占Eden
的1%
,但我们也可以通过-XX:TLABWasteTargetPercent
来修改百分比大小。 TLAB
区域默认开启。- 一旦对象在
TLAB
区域分配失败,JVM
就会尝试使用锁来确保数据操作的原子性,从而直接在Eden
区域分配。
TLAB
对象分配过程:
⑨ 堆是分配对象的唯一选择吗?
不是。
随着Java
的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术会使原有的绝对堆上分配发生一些微妙的变化。
,⑩ 逃逸分析
如果一个对象并没有逃逸出方法的话,那么就可能被优化为栈上分配。
- 如果有个对象在方法中被定义,且只在方法内部使用,则认为它没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法引用,则认为发生了逃逸。
如何将一个会逃逸的对象变成非逃逸对象?
//逃逸
public StringBuilder test() {
StringBuilder sb = new StringBuild();
return sb;
}
//非逃逸
public String test() {
StringBuilder sb = new StringBuild();
return sb.toString();
}
⑩① 使用逃逸分析进行优化
1) 栈上分配
public class Main {
public static void main(String[] args) {
long start = System.currentTimeMillis();
/* 执行一千万次 */
for (int i = 0; i < 10000000; i++) {
CreateObj();
}
long end = System.currentTimeMillis();
System.out.println("耗时:" + (end - start) + "ms");
}
public static void CreateObj() {
/* 未发生逃逸 */
User user = new User();
}
}
class User { }
首先我们关闭逃逸分析。
得到运行耗时:46ms
。
开启逃逸分析。
得到运行耗时:2ms
。
修改代码:
public class Main {
public static void main(String[] args) {
long start = System.currentTimeMillis();
User user = null;
/* 执行一千万次 */
for (int i = 0; i < 10000000; i++) {
user = CreateObj();
}
/* Obj 可能被其他线程继续使用 */
userObj(user);
long end = System.currentTimeMillis();
System.out.println("耗时:" + (end - start) + "ms");
}
public static User CreateObj() {
/* 发生逃逸 */
User user = new User();
return user;
}
public static void userObj(User user) { }
}
class User {
public int age = 5;
}
使代码发生逃逸,且开启逃逸分析。
得到运行时间:43ms
。
2) 同步省略
如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
3) 分离对象或标量替换
有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU
寄存器中。
标量指一个无法再分解成更小得数据的数据,例如基本数据类型就是标量。
聚合量指的是那些还可以分解为 标量 或 聚合量 的数据。
**标量替换:**在JIT
阶段,如果经过逃逸分析,发现一个对象并没有发生逃逸,则会将这个对象拆解为多个标量的状态。
例如:
public class Main {
public static void main(String[] args) {
test();
}
public static void test() {
Point point = new Point();
}
}
class Point {
int x;
int y;
}
此时,经过标量替换,栈中Point
实例会被替换为。
public class Main {
public static void main(String[] args) {
test();
}
//此处被替换
public static void test() {
int x;
int y;
}
}
class Point {
int x;
int y;
}