1.堆概述
方法区和堆是线程共享的,是每个进程唯一的,一个java程序对应一个进程,一个进程对应jvm实例,一个jvm实例拥有一个单例的运行时数据区,堆是java内存管理的核心区域
- 堆的大小可以被调节,通过-Xms和-Xmx参数调节
- 堆在jvm启动时即创建,空间大小也就随之确定
- 所有线程共享堆,可以设置线程私有缓冲区TLAB(文章稍后介绍)
- 堆在物理上不连续,在逻辑上连续,方法区是堆的逻辑上的一部分
2.堆内存细分
java堆是垃圾收集器管理的内存区域,故也叫GC堆,从内存回收的角度看,现代垃圾收集器大部分是基于分代收集理论设计的,所以java堆中会经常出现如图所示的内存划分(新生代包括伊甸区和幸存区),根据不同jdk版本(以jdk8划分),堆内存划分也不同:
2.1设置堆内存大小
- -Xms:堆起始内存,相当于-XX:InitialHeapSpace
- -Xmx:堆最大内存,相当于-XX:MaxHeapSpace
一般情况下将这两个参数的值设为相同,当堆区的内存超出设置的最大内存时,将会抛出OutOfMemoryError异常,jvm在计算堆内存的方式与我们不同,比如我们计算堆内存是新生代+老年代,而jvm则是不同,如图所示,堆内存为600m,jvm计算出来只有575m,因只计算了Eden和其中一块Survivor空间
2.2新生代和老年代
在上面我们提到了,新生代分为Eden,Survivor(S0,S1/From Survivor,To Survivor)区,为什么这样划分呢?原因是java中的对象生命周期导致:
- 对于生命周期较短的对象,创建和消亡都非常迅速
- 某些对象生命周期很长,甚至可以与jvm生命周期一致
新生代与老年代在堆中内存占比: -XX:NewRation=2,表示新生代占1,老年代占2
关于新生代中Eden和Survivor区的内存划分::-XX:Survivor=8,默认情况下Eden和sos1的划分是8:1:1
以上两个参数一般不需要调整,这里需要说明的是:
- 几乎所有的对象都是在Eden区被new出来的,且绝大部分的对象的销毁都在新生代中进行
- 在养老区,当内存不足时,会触发Major GC,对老年代的对象进行垃圾回收,如果还是内存不足,则会产生OOM异常
至于为什么要在新生代中设置这一内存占比,跟后面的标记-复制算法有很大关系,现在主流的java虚拟机都采用了这种方式去回收新生代,因为大多数对象的生命周期很短,采用复制算法可以将Eden和其中一个Survivor中少量的存活对象复制到另一块Survivor空间,这样使得垃圾回收效率大大增加,这也从侧面说明了在计算新生代内存空间时只计算了一块Survivor空间,另一块Survivor空间是‘浪费掉’的,也就是整个新生代10%的空间是没有被计算的。IBM的一次针对新生代对象的研究,发现有98%的对象熬不过第一轮收集,这只是在一次普通场景下做的测试,无法百分百保证每次回收都只有不多于10%对象存活,也就是在某些特殊情况下存活的对象大于10%,那么另一块Survivor不足以容纳一次Minor GC后存活的对象,就会将对象存放到其他区域,进行分配担保,综上所述,这种8:1:1的内存分配策略被称为Appel式回收,HotSpot虚拟机的Serial,PaeNew等年轻代收集器都采用了这种策略来设计新生代内存布局
3.对象分配过程
- 1.对象优先在Eden中分配
- 2.当Eden空间填满时,程序还要继续创建对象,这是会触发YGC/Minor GC对新生代(Eden和Survivor)进行垃圾回收,将Eden中没有引用的对象销毁,再创建新的对象到Eden中
- 3.在Minor GC回收后的Eden中,没有被销毁的对象移动到Survivor0中
- 4.若Eden区满了再次触发垃圾回收,并且垃圾回收过后,此前在Survivor0中仍然存在的对象会被移到Survivor1中,经历多次移动,达到jvm规定的阈值(15次),就会被移动到养老区了,至于阈值,可以通过:-XX:MaxTenuringThreshold=< N >进行设置
注意:Eden满了会触发Minor GC,而Survivor区满了不会触发,且Minor GC会对新生代进行垃圾回收,且垃圾回收在新生代频繁进行,很少在养老区搜集,几乎不再方法区搜集
3.1对象分配特殊情况
在创建对象的时候,会遇到一些特殊情况,比如在创建一个非常大的对象的时候,因为是在Eden中创建,当对象的大小大于Eden区的大小,会触发垃圾回收,在回收后仍然无法存放对象,一般会去老年区尝试存放,老年区比Eden内存空间大,如果老年区也无法存放就会抛出OOM异常,下面这张图清晰的展示了遇到特殊情况时JVM的处理过程
4.几种垃圾收集比较
对于GC不太了解的可以看一下关于GC垃圾回收及其算法的一点思考,里面有对GC详细的阐述
在jvm进行垃圾回收时,会根据内存区域的不同进行垃圾回收,以HotSpot VM为例,它的GC按照回收区域划分为部分收集(Partial gc)和整堆收集(Full GC)
- 部分收集
- 新生代收集:Minor GC/Young GC,新生代的垃圾收集
- 老年代收集:Major GC/Old GC,老年代的垃圾收集,只有CMS GC会有单独收集老年代的行为
- 混合收集(Mixed GC):收集整个新生代和部分老年代的垃圾收集,目前只有G1 GC有这种行为
- 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集
下面分别介绍这几种垃圾收集机制,这里只是简单介绍这些机制的用处,并不会深入解析
4.1Minor GC
- 当年轻代空间(Eden)空间不足时会触发Minor GC,Survivor满不会触发Minor GC
- 因为Java对象大多都具备朝生夕死的特征,所以Minor GC非常频繁,所以垃圾回收主要发生在新生代
- Minor GC会引发STW,暂停其他用户线程,当垃圾回收结束,用户线程才恢复运行
4.2Major GC
即老年代GC触发机制、
- 当对象从老年代消失,我们就会说Major GC或Full GC
- 出现Major GC,经常会伴随至少一次的Minor GC(当老年代空间不足时,会先尝试触发Minor GC,如果之后空间还不足,就会触发Major GC)
- Major GC的速度一般比Minor GC慢10倍以上,STW时间更长
- Major GC之后内存还不足,就会OOM
4.3Full GC
当出现以下几种情况会触发Full GC
- 调用System.gc()
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden,S0(From区)向S1(To区)复制时,对象大小大于To区可用内存,则把对象转入老年代,且老年代可用空间大小小于该对象大小
5.为什么要分代
前面我们说过,不同对象的生命周期不同,且70%-99%的对象都是临时对象,如果不分代,就相当于将所有对象放在一块管理,这样对GC性能影响太大,而分代的唯一理由就是优化GC性能,新生代存储的对象大多都是朝生夕死,这个区域也是GC最频繁的地区,类似数据库连接池对象则放在老年代,这样的分代管理就大大优化GC性能
6.本地线程缓冲TLAB
6.1为什么要有TLAB(Thread Local Allocation Buffer)
我们都知道堆是线程共享的,并且对象在虚拟机中的创建是非常频繁的,在并发情况下是不安全的,比如正在给A对象分配内存,指针还没来得及修改,对象B又使用了原来的指针,解决这个问题有两种方式,一种是对分配空间的动作进行同步,这种方式影响分配速度,另一种就是每个线程在堆中预先分配一小块内存,称为本地线程分配缓冲
6.2什么是TLAB
从内存模型的角度来看,在堆中的Eden区域,jvm为每个线程分配了一小块空间,如图所示
通过TLAB,可以避免一些线程安全问题,因为每个线程的对象创建销毁都是在本地线程的TLAB空间中进行,属于线程私有,不会造成并发问题,并且能够提升内存分配的吞吐量,因此这种内存分配方式被称为快速分配策略,JVM将TLAB作为内存分配的首选,我们可以在程序中通过-XX:UseTLAB设置是否开启TLAB空间
默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,通过选项-XX:TLABWasteTargetPertcent重新设置所占Eden空间百分比,一旦对象在TLAB空间分配内存失败,JVM就会尝试通过加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存
7.堆是否是对象存储的唯一选择
到目前为止我们所说的对象都是在堆上创建的,在《深入理解Java虚拟机》中有这样一段描述:
随着JIT编译期的发展和逃逸分析技术逐渐成熟,栈上分配,标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么绝对了
言外之意,通过其他技术类似逃逸分析技术的发展,对象可以不只分配在对上,也可以在栈上分配了
那么什么是逃逸分析
7.1逃逸分析
举个例子
class Demo{
void fun(){
Demo d = new Demo();
}
}
在fun方法中过的对象仅仅作用在这个方法内,即没有逃逸到方法外,对于这种对象,可以将其分配到栈上,随着方法的结束进行弹栈,栈空间被移除,对象也就被销毁,不存在GC问题,所以经过逃逸分析,我们可以对代码进行多种优化方式
7.2栈上分配
和在逃逸分析中举得例子一样, 对象尽可能的写成局部变量的方式,可以避免垃圾回收,提高程序性能,但值得注意的是:
- 逃逸技术并不是很成熟,且HotSpot虚拟机并没有采用逃逸分析技术,所以到目前为止,所有的对象还是分配到java堆上的
7.3同步省略
线程的同步往往会带来程序性能的下降,举个例子:
未发生逃逸的对象可以省略同步代码块,进而提高性能
7.4标量分配
首先要知道什么是标量(Scalar),值无法再分解的数据,对应着java的原始数据,与之相对的就叫做聚合量,对象就是聚合量,看下面的代码:
public class heapTest {
public static void main(String[] args){
alloc();
}
static void alloc(){
Demo d = new Demo();
System.out.println(d.x + " " + d.y);
}
}
class Demo{
int x;
int y;
}
在alloc函数中,对象未发生逃逸,进而可以将代码拆分为:
static void alloc(){
int x;
int y;
System.out.println(d.x + " " + d.y);
}
在经过拆分后,大大减少了堆内存的占用
8.STW(Stop The World)
在GC时间发生过程中,会产生应用程序的卡顿,停顿时产生整个应用线程都会被暂停,没有任何响应,优点类似卡死的感觉,这个停顿成为STW,被STW中断的用户线程会在完成GC后恢复,STW事件和采用哪款GC无关,所有的GC都有这个事件,垃圾收集器不能完全避免STW,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间,STW是JVM在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉,所以在开发中不要用System.GC,会导致STW发生