堆的核心概述
- 一个JVM实例只存在一个堆内存。
- 在JVM启动的时候就已经创建了,也确定了空间大小,可以利用-Xms 和-Xmx设置最大最小空间,通常设置一样,目的是能够在GC后不需要重新分隔计算堆区的大小
- 对可以处于物理上不连续但是逻辑上是连续的。
- 所有线程共享一个堆,在这里可以划分线程私有的缓冲区TLAB。
内存细分
Jdk8 之后堆的逻辑上分为三部分:新生区+养老区+元空间
- Young Generation Space 新生区
- 又被划分为 Eden 和Survivor1 和 Survivor2
- Tebure generation space 养老区
- Mate Space 元空间(方法区)
年轻代和老年代
-
存储在JVM中的对象可以划分为两类(具体划分看下图)
- 一类是什么周期较短的对象
- 另一类是生命周期非常长的对象
-
配置新生代与老年代在堆结构中占比(开发中一般不会调整)
- 默认- XX:NewRatio=2 表示新生代占 1 ,老年代占 2
- 在HotSpot中 Eden:s0:s1 = 8:1:1 可以通过-XX:SurvivorRatio =8 调整。-XX:-UseAdaptiveSizePolocy 关闭自适应内存分配策略(没什么用)。 需要显示指定
-
几乎所有的Java对象都是在Eden区被new出来的,
-
绝大部分的Java对象的销毁都在新生区进行。
-
可以使用选项“-Xmn” 设置新生代最大内存大小。
对象分配空间
- new的对象首先先放在Eden区中(对大小有限制)
- 当Eden区中内存满了,这时候会对年轻代进行垃圾回收(YGC/Minor GC)
- 并且将没有被清除的对象放入空的survivor区中,并且给其年龄计数器设置为1,并且将另一个survivor区中的对象放入这个survivor区并且年龄加一,如果有对象年龄到达15(可以通过-XX:MaxTenuringThreshold = 设置 )会将其晋升到 Tenured区去。
TLAB
为什么要?
- 堆区市线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于实例对象的创建在jvm中非常频繁,因此在并发条件下从堆区划分内存空间需要加锁,影响分配数速度。
TLAB应运而生
- 从内存模型的角度出发,将Eden区域继续划分,jvm为每一个线程分配出一个私有缓存区域,
- 多线程分配的时候,TLAB可以避免一系列非线程安全问题。提升吞吐量,可以称为快速分配策略
堆空间中常用的参数
-
-XX:+PrintFlagsInitial:查看所有的参数的默认初始值
-
-XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
- jps:查看当前运行中的进程
- jinfo -flag SurvivorRatio 进程id
-
-Xms:初始堆空间内存(默认为物理内存的1/64)
-
-Xmx:最大堆空间内存(默认为物理内存的1/4)
-
-Xmn:设置新生代的大小(初始值及最大值)
-
-XX:NewRatio:配置新生代与老年代在堆结构的占比
-
-XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
-
-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
-
-XX:+PrintGCDetails:输出详细的GC处理日志
-
-XX:+PrintGC 或 -verbose:gc :打印gc简要信息
-
-XX:HandlePromotionFalilure:是否设置空间分配担保
空间分配担保
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
如果大于,则此次Minor GC是安全的
如果小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允担保失败。
如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
如果小于,则进行一次Full GC。
如果HandlePromotionFailure=false,则进行一次Full GC。
对象不一定都在堆上
逃逸分析
- 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
- 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
- 逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
-
在JDK 1.7 版本之后,HotSpot中默认就已经开启了逃逸分析。如果使用的是较早的版本,开发人员则可以通过:
- 选项“-XX:+DoEscapeAnalysis"显式开启逃逸分析
- 通过选项“-XX:+PrintEscapeAnalysis"查看逃逸分析的筛选结果
栈上分配
- JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。
- 分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
- 常见的栈上分配的场景:在逃逸分析中,已经说明了,分别是给成员变量赋值、方法返回值、实例引用传递。
同步省略
-
线程同步的代价是相当高的,同步的后果是降低并发性和性能。
-
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
public void f() {
Object hellis = new Object();
synchronized(hellis) {
System.out.println(hellis);
}
}
//可以优化到。因为同步的对象只在方法体内有效。
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}
分离对象
-
标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
-
相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
-
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换
参数 -XX:+ElimilnateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。
总结
不足
-
关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟的。
-
其根本原因就是 无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
-
虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。
-
据我所知,Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
-
目前很多书籍还是基于JDK7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。
堆小结
-
年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
-
老年代放置长生命周期的对象,通常都是从Survivor区域筛选拷贝过来的Java对象。
-
当然,也有特殊情况,我们知道普通的对象可能会被分配在TLAB上;
-
如果对象较大,无法分配在 TLAB 上,则JVM会试图直接分配在Eden其他位置上;
-
如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代。
-
当GC只发生在年轻代中,回收年轻代对象的行为被称为Minor GC。
-
当GC发生在老年代时则被称为Major GC或者Full GC。
-
一般的,Minor GC的发生频率要比Major GC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。