深入理解java虚拟机 -- 堆区,学习java内存分布这一篇就够了

目录

堆区(Heap):

对象的创建

虚拟机为新生对象分配内存的两种分方式:

并发情况下如何保证对象在虚拟机分配内存是安全的

解决这个问题有两种可选方案:

对象的内存布局

对象头(Header)

实例数据(Instance Data)

对齐填充(Padding)

对象头(Header)-- 对象头的三部分

1. Mark Word(标记字)

此部分的用处:

2. Klass Word(类指针)

3. 数组长度

对象的访问定位

句柄访问

直接指针访问

句柄访问和直接指针访问对比

Java堆的内存划分

 Heap Memory 又被分为两大区域:

整体的详细架构图:

  1.Young/New Generation 新生代

新生代GC(Minor GC):

 2.Old/Tenured Generation 老年代

 当一个Object被创建后,内存申请过程如下:

Full GC的介绍:


 

堆区(Heap):


堆区是 理解Java GC机制  最重要的区域,没有之一。在JVM所管理的内存中,堆区是最大的一块,堆区也是Java GC机制所管理的主要内存区域,堆区由所有线程共享,在虚拟机启动时创建。堆区的存在是为了存储对象实例,原则上讲,所有的对象都在堆区上分配内存(不过现代技术里,也不是这么绝对的,也有栈上直接分配的)。

  Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出 OutOfMemoryError 异常。
 

堆中大部分对象都是通过new关键字创建对象,下面先看一下对象是如何创建的。

 

对象的创建

Java是一门面向对象的编程语言,Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象通常(例外:复制、反序列化)仅仅是一个new关键字而已,而在虚拟机中,对象(文中讨论的对象限于普通Java对象,不包括数组和Class对象等)的创建又是怎样一个过程呢?当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

 

虚拟机为新生对象分配内存的两种分方式:

 

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。

  1. 假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)
  2. 但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为 “空闲列表”(Free List)

选择哪种分配方式由Java堆是否规整决定, 而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。

 

指针碰撞(Bump The Pointer):

 

空闲列表(Free List):

 

 

并发情况下如何保证对象在虚拟机分配内存是安全的

 

除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

 

解决这个问题有两种可选方案:

  1. 一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性
  2. 另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local AllocationBuffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。

概况一下内存保证分配安全的方式就是:

  1. CAS+失败重试  (关于CAS的可以看一下另一篇博客  高并发编程 -- Java中CAS详解
  2. TLAB 

虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

 

CAS+失败重试
首先我们说下CAS+失败重试的方式,我们知道CAS是乐观锁的一种实现方式,通过比对原值和旧的预期值来确定是否要将原值更改为新值,如果通过CAS来实现线程安全,那么需要三个因子,首先是在主内存中的一个原值,然后第二个是这个原值在各个线程中的副本,再接下来就是新值。

如果采用的是指针碰撞的方式进行对象的内存分配,那么这个原值就是当前指针的位置,假设说现在是初始化状态,那么指针的位置就是0,也就是原值就是0,这个0会在主内存中存放着,假设现在有两个线程A和B,每个线程都在执行着创建对象的操作,这两个线程中保存着原值的副本,此时的原值是0,副本也是0,那么新值呢?新值就是需要的内存量,假设说对象A需要5的内存,对象B需要7的内存。

现在两个线程开始创建对象,我们采用CAS+失败重试的方法来确保线程安全。

首先A线程开始创建对象,此时触发乐观锁的机制,A读取到了目前的内存情况,也就是指针的初始位置0,因为是乐观锁,所以在没有提交的时候并不会触发冲突检查,这个时候时间切换到线程B,线程B也开始创建对象,同样读取到了目前的内存情况,同样的指针位置是0,因为没有提交,同样不会触发冲突检查,但是线程的工作是CPU轮流安排时间片进行的,同一时间只会有一条线程执行任务,这个时候又切换到线程A,A开始提交,提交的时候触发了冲突检查,对原值以及旧的预期值进行比对,原值是0,预期原值也就是线程里面的原值副本,此时线程A的旧的预期值也是0,比对通过,将新值赋给原值,新值是5,那么指针就会移动5个单位,此时指针的位置就是在5,同时将线程A的预期值更改为5,现在时间再次切换到线程B,线程B开始提交,触发乐观锁机制,进行冲突检查,此时的原值是5,线程B的旧的预期值是0,比对不通过,此时虚拟机什么都不做,只是把线程B的旧的预期值更新为5,然后触发失败重试,线程B会再次尝试提交,再次出发冲突检查,那么这个时候检查就通过了,通过之后将新值赋给原值,需要注意的地方是,这个赋值并不是把5改变成7,而是将指针移动7个单位,移动到12,然后再把线程B的旧的预期值更新为12,以此类推。

那么如果是空闲列表呢?又是如何通过CAS+失败重试的方式来保证线程安全呢。

其实道理是一样的,同样的AB两个线程都需要创建对象分配空间,A需要5,B需要7。

首先是A线程,此时的原值是可用的内存空间0-100,A和B的旧的预期值也都是0-100,这个时候A先去创建对象,检查通过后,虚拟机分配了7-12的内存给了A线程的对象,同时将原值以及A线程的旧的预期值更改为0-7,12-100,这个时候B再去提交创建对象的时候,旧的预期值是0-100,与0-7,12-100比对不通过,将预期值更新为0-7,7-12,然后触发失败重试,再次提交的时候比对通过,提交成功,更新原值和线程B的预期值,以此类推。


TLAB
那么如果是TLAB的方式呢?

TLAB,全文Thread Local Allocation Buffer,本地线程分配缓冲,每个线程都会在Eden空间申请到一个TLAB,大小占Eden空间的1%,当然申请这个空间的过程是线程同步的,这个同步的实现也是依赖于CAS+失败重试的方式,具体我们就不再详细介绍,只是原值和新值不同而已,可以把TLAB想象成一个对象,占用内存大小就是Eden空间的1%即可。

当这个线程需要创建对象的时候,直接在TLAB里面创建就行了,这样就避免因并发而导致的线程安全问题。

当然,有这样的一种情况,现在这个线程需要创建一个对象,但是当前的TLAB的空间不足了,怎么办,它会再向Eden空间去申请一个TLAB,申请的过程是线程同步的,它会把这个对象放到新的TLAB中,也就是说一个线程并不是只有一个TLAB。

那如果这个对象特别大,哪怕是一个新的TLAB也放不下呢?直到这个时候,线程才会去把对象直接创建在Eden空间,再次采用CAS+失败重试的方法去保证线程同步。

也就是说,采用这种方式,线程会向Eden空间申请线程私有的TLAB来创建对象,确保线程安全,除非说现在的TLAB不够用了,再去申请新的TLAB的时候才会同步锁定,或者说是对象特别大,一个全新的TLAB空间都装不下了,必须去Eden空间创建,才会同步锁定。

但一般来说,创建的对象都是特别小的,也都是会迅速销毁的,所以这种方式从效率上来讲还是比较高的。

 

内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即Class文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。

 

对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)。

对象的几个部分的作用:

1.对象头中的 Mark Word(标记字) 主要用来表示对象的线程锁状态,另外还可以用来配合GC存放该对象的hashCode

2.Klass Word是一个指向方法区中Class信息的指针,意味着该对象可随时知道自己是哪个Class的实例;

3.数组长度也是占用64位(8字节)的空间,这是可选的,只有当本对象是一个数组对象时才会有这个部分;

4.对象体是用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型;

5.对齐字是为了减少堆内存的碎片空间。
 

 

对象头(Header)

HotSpot虚拟机对象的对象头部分包括两类信息。第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容

 

对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,这点我们会在下一节具体讨论。此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

HotSpot虚拟机代表Mark Word中的代码(markOop.cpp)注释片段,它描述了32位虚拟机Mark Word的存储布局:

 

实例数据(Instance Data)

实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary ObjectPointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

对齐填充(Padding)

对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

 

对象头(Header)-- 对象头的三部分

1. Mark Word(标记字)

Java对象处于5种不同状态时,Mark Word中64个位的表现形式,上面每一行代表对象处于某种状态时的样子。

lock: 2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个Mark Word表示的含义不同。biased_lock和lock一起,表达的锁状态含义如下:

biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。lock和biased_lock共同表示对象处于什么锁状态。

age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。

identity_hashcode:31位的对象标识hashCode,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该值会移动到管程Monitor中。

thread:持有偏向锁的线程ID。

epoch:偏向锁的时间戳。

ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。

ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。
 

通常说的通过synchronized实现的同步锁,真实名称叫做重量级锁。但是重量级锁会造成线程排队(串行执行),且会使CPU在用户态和核心态之间频繁切换,所以代价高、效率低。为了提高效率,不会一开始就使用重量级锁,JVM在内部会根据需要,按如下步骤进行锁的升级:

锁一共有4种状态

级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

 

此部分的用处:

synchronized的实现原理与应用:

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。本文详细介绍Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。先来看下利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下3种形式。

❑ 对于普通同步方法,锁是当前实例对象。

❑ 对于静态同步方法,锁是当前类的Class对象。

❑ 对于同步方法块,锁是Synchonized括号里配置的对象。

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。那么锁到底存在哪里呢?锁里面会存储什么信息呢?

从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

synchronized用的锁是存在 Java对象头里的 。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit

 

 

2. Klass Word(类指针)


这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。
如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops开启指针压缩,其中,oop即ordinary object pointer普通对象指针。开启该选项后,下列指针将压缩至32位:

每个Class的属性指针(即静态变量)
每个对象的属性指针(即对象变量)
普通对象数组的每个元素指针
当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。
 

3. 数组长度


 如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。
 

 

对象的访问定位

对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:

  1. 句柄访问
  2. 直接指针访问

 

句柄访问

如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息

直接指针访问

如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销

 

句柄访问和直接指针访问对比

使用句柄来访问:最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。

使用直接指针来访问:最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本书讨论的主要虚拟机HotSpot而言,它主要使用第二种方式进行对象访问(有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发,具体可参见第3章),但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也十分常见。

总结:

句柄来访问,方便 reference。

直接指针来访问,降低指针定位开销。

 

 

Java堆的内存划分

Java堆是被所有线程共享的一块内存区域,所有对象和数组都在堆上进行内存分配。为了进行高效的垃圾回收,虚拟机把堆内存划分成新生代、老年代和永久代(1.8中无永久代,使用metaspace实现)三块区域。

 Heap Memory 又被分为两大区域:

        - Young/New Generation 新生代

        新生对象放置在新生代中,新生代由Eden 与Survivor Space 组成。

        - Old/Tenured Generation 老年代

        老年代用于存放程序中经过几次垃圾回收后还存活的对象

下面是一个

整体的详细架构图:

 

JDK7 之前的堆内存划分:

Java虚拟机将堆内存划分为新生代、老年代和永久代,永久代是HotSpot虚拟机特有的概念(JDK1.8之后为metaspace替代永久代),它采用永久代的方式来实现方法区,其他的虚拟机实现没有这一概念,而且HotSpot也有取消永久代的趋势,在JDK 1.7中HotSpot已经开始了“去永久化”,把原本放在永久代的字符串常量池移出。永久代主要存放常量、类信息、静态变量等数据,与垃圾回收关系不大,新生代和老年代是垃圾回收的主要区域。

永久代(Permanent Generationn)

  永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,对这一区域而言,Java虚拟机规范指出可以不进行垃圾收集,一般而言不会进行垃圾回收。

 

永久代和方法区

1、方法区

  方法区(Method Area)是jvm规范里面的运行时数据区的一个组成部分,jvm规范中的运行时数据区还包含了:pc寄存器、虚拟机栈、堆、方法区、运行时常量池、本地方法栈。主要用来存储class、运行时常量池、字段、方法、代码、JIT代码等。运行时数据区跟内存不是一个概念,方法区是运行时数据区的一部分。方法区是jvm规范中的一部分,并不是实际的实现,切忌将规范跟实现混为一谈。

2、永久代

  永久带又叫Perm区,只存在于hotspot jvm中,并且只存在于jdk7和之前的版本中,jdk8中已经彻底移除了永久带,jdk8中引入了一个新的内存区域叫metaspace。并不是所有的jvm中都有永久带,ibm的j9,oracle的JRocket都没有永久带,永久带是实现层面的东西,永久带里面存的东西基本上就是方法区规定的那些东西。

3、区别

  方法区是规范层面的东西,规定了这一个区域要存放哪些东西,永久带或者是metaspace是对方法区的不同实现,是实现层面的东西。

 

 

  1.Young/New Generation 新生代


        程序中新建的对象都将分配到新生代中,新生代又由Eden(伊甸园)与两块Survivor(幸存者) Space 构成。Eden 与Survivor Space 的空间大小比例默认为8:1,即当Young/New Generation 区域的空间大小总数为10M 时,Eden 的空间大小为8M,两块Survivor Space 则各分配1M,这个比例可以通过-XX:SurvivorRatio 参数来修改。Young/New Generation的大小则可以通过-Xmn参数来指定。

        Eden:刚刚新建的对象将会被放置到Eden 中,这个名称寓意着对象们可以在其中快乐自由的生活。

        Survivor Space:幸存者区域是新生代与老年代的缓冲区域,两块幸存者区域分别为s0 与s1,当触发Minor GC 后将仍然存活的对象移动到S0中去(From Eden To s0)。这样Eden 就被清空可以分配给新的对象。
        当再一次触发Minor GC后,S0和Eden 中存活的对象被移动到S1中(From s0To s1),S0即被清空。在同一时刻, 只有Eden和一个Survivor Space同时被操作。所以s0与s1两块Survivor 区同时会至少有一个为空闲的,这点从下面的图中可以看出。

        当每次对象从Eden 复制到Survivor Space 或者从Survivor Space 之间复制,计数器会自动增加其值。 默认情况下如果复制发生超过16次,JVM 就会停止复制并把他们移到老年代中去。如果一个对象不能在Eden中被创建,它会直接被创建在老年代中。

 

新生代GC(Minor GC):

指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,通常很多的对象都活不过一次GC,所以Minor GC 非常频繁,一般回收速度也比较快。
        Minor GC 清理过程(图中红色区域为垃圾):

 

1.清理之前

 

  2.清理之后

图中的"From" 与"To" 只是逻辑关系而不是Survivor Space 的名称,也就是说谁装着对象谁就是"From"。 一个对象在幸存者区被移动/复制的次数决定了它是否会被移动到堆中。

 

 2.Old/Tenured Generation 老年代


        老年代用于存放程序中经过几次垃圾回收后还存活的对象,例如缓存的对象等,老年代所占用的内存大小即为-Xmx 与-Xmn 两个参数之差。
        堆是JVM 中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new 对象的开销是比较大的,鉴于这样的原因,Hotspot JVM 为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间,这块空间又称为TLAB(Thread Local Allocation Buffer),其大小由JVM 根据运行的情况计算而得,在TLAB 上分配对象时不需要加锁,因此JVM 在给线程的对象分配内存时会尽量的在TLAB 上分配,在这种情况下JVM 中分配对象内存的性能和C 基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配,TLAB 仅作用于新生代的Eden,因此在编写Java 程序时,通常多个小的对象比大的对象分配起来更加高效,但这种方法同时也带来了两个问题,一是空间的浪费,二是对象内存的回收上仍然没法做到像Stack 那么高效,同时也会增加回收时的资源的消耗,可通过在启动参数上增加 -XX:+PrintTLAB来查看TLAB 这块的使用情况。

 

        老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,通常会伴随至少一次Minor GC(但也并非绝对,在ParallelScavenge 收集器的收集策略里则可选择直接进行Major GC)。MajorGC 的速度一般会比Minor GC 慢10倍以上。

        虚拟机给每个对象定义了一个对象年龄(age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

 

 当一个Object被创建后,内存申请过程如下:


        1.JVM 会试图为相关Java 对象在Eden 中初始化一块内存区域。
        2.当Eden 空间足够时,内存申请结束。否则进入第三步。
        3.JVM 试图释放在Eden 中所有不活跃的对象(这属于1或更高级的垃圾回收), 释放后若Eden 空间仍然不足以放入新对象,则试图将部分Eden 中活跃对象放入Survivor 区。
        4.Survivor 区被用来作为新生代与老年代的缓冲区域,当老年代空间足够时,Survivor 区的对象会被移到老年代,否则会被保留在Survivor 区。
        5.当老年代空间不够时,JVM 会在老年代进行0级的完全垃圾收集(Major GC/Full GC)。
        6.Major GC/Full G后,若Survivor 及老年代仍然无法存放从Eden 复制过来的部分对象,导致JVM 无法在Eden 区为新对象创建内存区域,JVM 此时就会抛出内存不足的异常。

 

上图中,如果创建对象申请空间到最后的执行了Full GC,

Full GC的介绍:


Full GC 是发生在老年代的垃圾收集动作,所采用的是标记-清除算法。 
现实的生活中,老年代的人通常会比新生代的人 “早死”。堆内存中的老年代(Old)不同于这个,老年代里面的对象几乎个个都是在 Survivor 区域中熬过来的,它们是不会那么容易就 “死掉” 了的。因此,Full GC 发生的次数不会有 Minor GC 那么频繁,并且做一次 Full GC 要比进行一次 Minor GC 的时间更长,一般是Minor GC的 10倍以上。 
另外,标记-清除算法收集垃圾的时候会产生许多的内存碎片 ( 即不连续的内存空间 ),此后需要为较大的对象分配内存空间时,若无法找到足够的连续的内存空间,就会提前触发一次 GC 的收集动作。 

 

大型的应用系统常常会被两个问题困扰:
        一个是启动缓慢,因为初始Heap 非常小,必须由很多major 收集器来调整内存大小。
        另一个更加严重的问题就是默认的Heap 最大值对于应用程序来说“太可怜了”。根据以下经验法则(即拇指规则,指根据经验大多数情况下成立,但不保证绝对):
        (1)给于虚拟机更大的内存设置,往往默认的64mb 对于现代系统来说太小了。
        (2)将-Xms 与-Xmx 设置为相同值,这样做的好处是GC 不用再频繁的根据内存使用情况去动态修改Heap 内存大小了,而且只有当内存使用达到-Xmx 设置的最大值时才会触发垃圾收集,这给GC 及系统减轻了负担。
        (3)当CPU 数量增加后相应也要增加物理内存的数量,因为JVM 中有并行垃圾收集器。 

 下面是几种断代法可用GC汇总:

 

 

 GC 的默认使用情况:

  新生代 老年代/永久代
Client 串行收集器 串行收集器
Server 并行压缩收集器 CMS

 

 

 

参考自:

《JVM高级特性与最佳实践(第3版)》

https://www.jianshu.com/p/d48c9b0fc1b4

https://baijiahao.baidu.com/s?id=1639566514819627231&wfr=spider&for=pc

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章