JVM-Java虚拟机内存管理机制

Java虚拟机内存管理机制

1 Java内存区域与内存溢出异常

1.1 运行时数据区域

方法区(Method Area)

线程共享的一个区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

在HotSpot中也叫永久代(Permanent Generation),但实际上两者不等价,只是因为HotSpot将GC扩展到了方法区。

堆(Heap)

对大多数应用来说虚拟机内存中最大的一块。所有线程共享的一块区域,虚拟机启东时创建。堆的唯一目的就是存放所有的对象实例。Java虚拟机规范描述:所有的对象实例以及数组都要放在堆上分配,但随着JIT编译器的发展以及逃逸分析技术逐渐成熟,栈上分配、标量替换、优化技术将会导致一些微妙的变化发生,所有的对象都在堆上分配也不再那么“绝对”。

Java堆是垃圾收集器管理的主要区域,也常常叫做“GC堆”(Garbage Collection Heap)。从内存回收的角度,因为主要采用分带收集算法,所以堆2可以细分为新生代和老年代,再细的话有Eden区,From, Survivor区,To Survivor区。从内存分配的角度,可以分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。

规范规定,堆的空间中物理上不连续,逻辑上连续。实现时,可以使固定的大小,也可以是可扩展的,通过 -Xmx 和 -Xms 控制。当堆中没有内存空间足够完成对象实例分配,并且堆无法扩展的时候,将会抛出OutOfMemoryError。

虚拟机栈(VM Stack)

线程所私有的,生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同事会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。也是常说的虚拟机的栈内存。

两种异常情况:1.如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;2.如果虚拟机栈可以动态扩展,如果无法申请到足够的内存,将抛出OutOfMemoryError异常。

本地方法栈(Native Method Stack)

虚拟机栈为执行Java方法(字节码)服务,本地方法栈为虚拟机使用Native方法时服务,也是线程私有的,也是规定了以上两种异常。

程序计数器(Program Counter Register)

可以理解为当前线程所执行的字节码的行号指示器。每个线程都有一个程序计数器,归于每个线程私有,当执行的方法为Native方法时,PCR的值为空,在规范中,该区域没有OutOfMemoryError异常。

运行时常量池(Running Constant Pool)

运行时常量池属于方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池(Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用,这部分内容在类加载后进入方法去的运行时常量池中存放。

受方法区内存的限制,当常量池无法在申请到内存时抛出OutOfMemoryError异常。

1.2 对象创建过程

创建

注:这里所说的对象不包括数组和Class对象。

1.当遇到new关键字指令时,首先检查常量池中是否有该类的符号引用,检查该引用所代表的类是否被加载、解析和初始化过。如果没有,先执行类加载过程。

2.类加载检查通过后,接着为新生对象分配内存。分配方式分为两种:1)指针碰撞:假设堆中内存时规整的,即用过的内存放在一边,控线的内存放在另一边,中间放着一个指针作为分界点的指示器,分配的时候就是把指向空闲空间的那边挪动一段与对象大小相等的距离。2)空闲列表:当内存不规整,无法使用指针碰撞的时候。VM维护一个列表,记录哪些内存块是可用的,分配的时候就从列表中找出一块足够大的空闲内存划分给对象实例,并更新列表。

以上两种分配方式在并发情况下是非线程安全的。两种解决方案:1)对分配内存空间的动作进行同步处理,即采用CAS+失败重试保证更新操作的原子性;2)把内存分配动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存——本地线程分配缓冲(Thread Loacal Allocation Buffer, TLAB),然后哪个线程需要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才进行同步锁定。是否使用TLAB,通过 -XX: +/-UseTLAB参数设定。

3.内存分配完成后,将分配到的内存空间都初始化为零值(不包括对象头)。如果使用TLAB,这一操作可以提前到TLAB进行。这个操作保证了对象实例未被赋予初始值也可以直接使用,可以访问该字段对应的零值。

VM对对象进行设置,如对象是哪一个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄,在当前运行状态中是否启用偏向锁等信息,这些信息都放在对象头(Object Header)中。

布局

在HotSpot中,对象在内存中存储的布局分为3块区域:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding)。

1. 对象头

对象头包括两部分信息。

1)Mark Word:存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。因运行时数据较大,所以会复用存储空间,如表1

表1

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向所记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量及锁定)
空,不需要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

2)类型指针:即对象指向它的类元数据的指针,通过该指针来确定对象是哪个类的实例。并不是所有虚拟机实现都有类型指针。

注:对象是Java数组的时候,对象头中必须还有一块用于记录数组长度的数据。

2. 实例数据

该部分是存储对象的有效信息,即程序代码中所定义的各部分类型的字段内容。包括从父类继承、子类定义等。

3. 对其填充

对其填充并不一定存在,仅仅起着占位符的作用。

因为HotSpot VM中对象起始地址是8字节的整倍数,当实例数据没有对齐的时候,进行填充。

1.3 访问定位

两种主流方式,使用句柄直接指针。

1.句柄:Java堆中会划分出一块内存作为句柄池,Java栈本地变量表中存储的reference中存储的就是句柄地址,再句柄中包含对象实例数据与对象类型数据各自的具体地址信息。

2.直接指针:没有句柄部分,reference中直接存储的为对象实例数据和对象类型数据的地址。

句柄,因为reference中存储的是稳定的句柄地址,在对象移动(垃圾收集的时候移动是正常操作且普遍)的时候只改变句柄中的指针;直接指针则是速度开,没有多余的如句柄那样进行定位。

2. 垃圾收集器与内存分配策略

2.1 怎么确定对象已死?

在垃圾收集器对堆进行回收前,首先要确定哪些对象还“存活”,哪些对象已经“死去”,主要由两种办法。

1. 引用计数法(Reference Counting)

给对象添加一个引用计数器,每当一个地方引用它的时候,计数器就加1;当引用失效时,计数器值减1,当计数器值为0的时候,说明对象已经“死去”。

优点:实现简单,判定效率高效,大部分时间都是行之有效的。

缺点:难以解决对象间的循环引用问题。

2. 可达性分析算法(Reachability Analysis)

Java用到的也是这个算法。

基本思路: 通过一系列的成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连接时,证明该对象是不可用的。

Java中GC Roots包括:虚拟机栈(栈帧中的本地变量表)中引用的对象,方法区中类静态属性引用的对象,方法区中常量引用的对象,本地方法中JIN(一般所说的Native方法)引用的对象。

引用级别

强引用(Strong Reference),代码中普遍存在的,如 Object obj = new Object(),只要强引用还在,垃圾收集器永远不会回收被引用的对象。

软引用(Soft Reference),描述一些还有用但并非必要的对象。在系统将要发生内存溢出异常前,会对软引用的对象进行第二次回收。如果回收之后内存还不够的话,就会抛出内存溢出异常。用SoftReference类实现。

弱引用(Weak Reference),亦是描述非必要对象,强度比软引用更弱,软引用的对象只能生存到下一次垃圾收集发生之前,当垃圾回收器工作时,无论内存是否足够,都会进行回收。用WeakReference类实现。

虚引用(Phantom Reference),也称为幽灵引用或幻影引用,最弱的引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。设置虚引用的目的就是收到一个回收的系统通知。用PhantomReference类实现。

在可达性分析中,第一次没有GC Roots相连接的引用链的时候,进行第一次标记,如果没有finalize()方法或finalize()方法已经被VM调用,则不会立即宣判对象的死亡,即不会立即进行回收,后续第二次可达性分析的时候进行第二次标记,然后进行回收。如果有finalize()方法,则会放入F-Queue的队列中,然后由Finalizer线程执行回收。

方法区的回收

(HotSpot)永久代的垃圾收集器主要回收两部分类容:废弃常量无用类。

废弃常量:当没有任何对象引用常量池中的该常量,也没有其他地方引用了这个字面量,如果此时发生回收,当有必要的时候该常量就会被回收。常量池中的其他类(接口)、方法、字段的符号引用也类似。

无用类:要同时满足3个条件。1)该类的所有势力都已经被回收,即堆中不存在该类的任何实例;2)加载该类的ClassLoader已经被回收;3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

2.2 垃圾回收算法

1. 标记-清除算法(Mark-Sweep)

标记-清除是最基础的收集算法。算法分为“标记”和“清除”两个阶段。

标记:标记出所有需要回收的对象,在标记完成后统一进行回收所有被标记的对象。

清除:将标记的对象进行回收操作。

后续的收集算法都是在这个思路上进行改进其不足而得到的算法。

不足:1)效率问题,标记和清除两个阶段的效率都不够高;2)标记清除之后会产生大量不连续的内存碎片。

2. 复制算法(Copying)

解决效率问题。将内存按容量划分为大小相等的两块,每次只使用其中一块。当其中一块内存使用完后,将还活着的对象复制到另外一块中,然后将原来使用过的那一块进行清理。

不用考虑碎片问题,只需要移动堆顶指针,按顺序分配内存即可;但是只是用到原内存的一半,内存的使用率不够高。但是当对象存活率较高的时候,复制操作会降低效率。

将内存分为Eden, Survivor, To Survivor三个部分,比例为8:1:1,每次将Eden和Survivor中的存活对象复制到To Survivor中,提高了内存的使用效率。当To Survivor的空间不足够的时候,就会移入老年代中。

3. 标记-整理(Mark-Compact)

根据老年代的特点,标记过程与标记-清除中的标记一样,但是后续步骤不是直接对可回收对象进行清理,而是先让所有存活对象向一端移动,最后直接清理掉端边界以外的内存。

4. 分代收集算法(Generational Collection)

根据对象存活周期的不同将内存划分为几块。一般把Java堆分为新生代和老年代,然后根据各个年代的特点采用最合适的收集算法。

新生代中每次垃圾回收的时候都有大批量的对象“死去”,可以直接采用复制算法,只需少量存活对象的复制成本就可以完成收集。

老年代中因对象存活率较高,且没有额外的空间进行“担保”(如To Survivor不足的时候进入老年代,那么老年代就是To Survivor部分的担保),则使用标记-清除或是标记-清理算法。

2.3 垃圾收集器

垃圾收集器是垃圾回收算法的具体实现。

 上图中是7种作用于不同分代的收集器,如果两个收集器之间存在连线,说明它们可以搭配使用。所在区域表示它是属于新生代收集器还是老年代收集器。

1. Serial收集器

单线程的收集器,当收集器工作的时候,会暂停其他所有工作线程,知道它收集结束。——Stop The World.

依然是VM运行在Client模式下的默认新生代收集器。

比起其他收集器简单高效

2. ParNew收集器

为Serial收集器的多线程版本,这是与Serial收集器的唯一区别。

Server模式下的虚拟机中首选的新生代收集器,目前至于CMS收集器配合工作。

垃圾收集器中的并行并发

并行(Parallel):指多条垃圾收集线程并行工作,但是此时用户线程依然处于等待状态。

并发(Concurrent):指用户线程与垃圾收集器线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。

3. Parallel Scavenge收集器

是一个新生代收集器,使用复制算法的收集器,并行的多线程收集器。

特点:CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。

吞吐量:CPU用于运行用户带啊的时间与CPU纵消耗的时间比值。

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。如VM公共运行了100分钟,其中垃圾收集器花掉1分钟,那么吞吐量为99%。

因此也被成为“吞吐量优先”收集器。

4. Serial Old收集器

是Serial收集器的老年代版本,也是单线程收集器,使用标记-整理算法。

主要在Client模式下的虚拟机使用。

5. Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用标记-整理算法。

主要与Parallel Scavenge收集器进行搭配使用。

6. CMS收集器(Concurrent Mark Sweep)

以获取最短回收停顿时间为目标的收集器。

大部分Java应用几种在互联网或者B/S系统的服务端,这类应用注重服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS符合该要求。

基于标记-清除算法是实现。

运行过程分为是个步骤:

1)初始标记(CMS initial mark)需要 Stop The World,只标记GC Roots能直接关联到的对象,速度快。

2)并发标记(CMS concurrent mark)进行GC RootsTracing过程,

3)重新标记(CMS remark)需要 StopThe World,为了修正并发标记期间因用户程序继续运行而导致标记产生变动部分的对象的标记记录,时间比初始标记阶段稍长,但远比并发标记阶段时间短。

4)并发清除(CMS concurrent sweep)

优点:并发收集、低停顿,也称为并发停顿收集器(Concurrent Low Pause Collector)

缺点:1)对CPU资源非常敏感,在并发回收垃圾线程时CMS默认启动的回收线程数量(CPU数量 + 3) / 4;2)无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC产生;3)因基于标记-清除算法实现的收集器,无法进行碎片整理,可以通过设置UseCMSCompactAtFullCollection和CMSFullGCBeforeCompaction对碎片进行整理。

浮动垃圾:当CMS并发清理阶段用户线程依然运行,伴随着程序运行会产生新的垃圾,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理它们,留给下一次GC是再进行清理。

7. G1收集器(Garbage-First)

是面向服务端应用的垃圾收集器。

特点:

1)并行与并发:能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop The World停顿的时间;

2)分代收集:分代概念在G1中依然保留;

3)空间整合:G1从整体上来看是基于标记-整理算法实现的收集器,从局部(两个Region之间)来看是基于复制算法实现的;

4)可预测的停顿:降低停顿时间是G1与CMS共同的关注点,G1通过建立可预测的停顿时间模型,让使用者明确指定在一个长度为M毫秒的时间片内消耗在垃圾收集器上的时间不得超过N毫秒。

G1收集器的运作大致分为4个步骤:

1)初始标记(Initial Marking),只标记GC Roots能直接关联到的对象,需要停顿线程,但是时间较短

2)并发标记(Concurrent Marking),从GC Roots开始对堆中对象进行可达性分析,找出存活的对象,耗时较长,可与用户程序并发执行;

3)最终标记(Final Marking),修正在并发标记期间因用户程序继续运作而导致标记产生变动的那部分标记记录,需要停顿线程,可并行执行

4)筛选回收(Live Data Counting and Evacuation),对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划,也可与用户程序并发执行。

 

2.4 对象分配与回收策略

Java技术体系中所提倡的自动内存管理最终可以归结为自动化的解决两个问题:为对象分配内存和回收分配给对象的内存。

1. 对象优先分配在Eden区,大多数情况,对象在新生代Eden区中分配,当Eden区空间不足够的时候回发生Minor GC。

Minor GC和Full GC区别:

新生代GC(Minor GC):指发生在新生代的垃圾收集动作,Minor GC较为频繁,因为Java对象朝生夕灭的特性,回收速度也快;

老年代GC(Major GC/Full GC):指发生在老年代的GC,出现Major GC时经常会伴随至少一次Minor GC(但非绝对,在Parallel Scavenge收集器上的手机策略里有直接进行Major GC的过程)。Major GC速度一般比Minor GC慢10倍以上。

2. 大对象直接进入老年代

大对象:需要大量连续内存空间的Java对象,比如长字符串和数组。

3. 长期存活的对象将进入老年代,VM给每个对象定义了一个对象年龄(Age)计数器,每进过一次Minor GC计数器加1,年龄达到一定程度度(默认15岁),就进入老年代咯。通过参数MaxTenuringThreshold设置。

4. 动态对象年龄判断,如果Survivor空间中相同年龄所有的对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需叨叨MaxTenuringThreshold所要求的年龄。

5. 空间担保分配,发生Minor GC前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果该条件成立,那么Minor GC是确定安全的;如果不成立,虚拟机会查看HandlePromotionFailure设置是否允许担保失败。如果允许,那么继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管该操作有风险;如果小于或是HandlePromotionFailure设置不允许,就改为Full GC。

参考《深入理解Java虚拟机(第二版)》所写。

猜你喜欢

转载自www.cnblogs.com/baishouzu/p/12312864.html