目录
java内存内存区域与oom
运行时数据区
jvm运行java程序时会将其管理的内存区域划分不同的数据区域.有些区域随着虚拟机进程启动而一直存在,还有些区域依赖用户线程的启动和结束而建立和销毁
程序计数器
program counter register,一块很小的内存区域,当前线程所执行的字节码的行号指示器.
jvm概念模型里,字节码解释器工作就是改变这个计数器的值来选取下一条需要执行的字节码指令.它是程序控制流的指示器,分支,循环跳转,异常处理县城回复等基础功能都是依靠这个计数器来完成.
特点:
- 线程私有:java多线程是通过线程轮流切换,分配处理器执行时间的方式来实现,因此为了能在切换后能恢复到正确的执行位置,每条线程都会有独立的程序计数器,各条线程的计数器互不影响.
- 不会发生oom:此内存区域中是在jvm规范中唯一一个没有规定oom情况的区域.
虚拟机栈
java virtual machine stack:描述的是java方法执行得线程内存模型,每个方法被执行的时候,jvm会同步创建一个栈帧,用于存储局部变量表,操作帧,动态链接,方法出口等信息,每一个方法被调用直至执行完毕的过程,就对应一个栈帧在虚拟机栈中从入栈到出栈的过程.
局部变量表
存放了编译期可知的
- 基本数据类型(short,byte,int,long,double,chart,boolean,float)
- 对象引用类型
- returnAddress类型
这些数据在局部变量表中以局部变量槽(slot)来表似,64位的long,double占用两个slot.
局部变量表所需的内存空间在编译器分配完成,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量孔氏完全确定的,在方法运行期间不会改变局部变量表的大小(指槽的数量,具体多大的内存空间,如32bit一个槽,64bit1个槽,按照jvm具体实现为准)
特点
- 线程私有
异常情况
- 线程请求深度大于虚拟机所允许的深度,抛出
StackOverflowError
异常 - 如果虚拟机栈容量可以动态扩展,当栈扩展时,无法申请到足够的空间,抛出
StackOverflowError
(hotspot不允许栈动态扩容)
本地方法栈
native method stack
类似jvm栈
hotspot将本地方法栈和虚拟机栈合二为一
堆
《jvm规范》中堆java堆的描述是:所有的对象及数组都应该再堆上分配。
由于即时编译技术的进步,尤其逃逸分析技术的日渐强大,栈上分配,标量替换优化手段已经导致java对象的实例都在堆上分配不再那么绝对.
堆中区域划分
- 从内存回收角度看:由于现代垃圾收集器大部分都是基于分代手机理论设计的,所以java堆中经常出现”新生代,年老代”等.
- 从分配内存角度看:所有线程共享的java堆中可以划分出多个线程私有的分配缓冲区(thread local allocation buffer,TLAB),以提升对象分配时的效率.通过 -xx:+/-UseTLAB参数设定
- 无论哪个区域,存储的都只能是对象的实例.
对象的存储
根据《java虚拟机规范》的规定,java堆中可以处于物理上不连续的内存空间,但在逻辑上它应该被视为连续的.但对于大对象(典型的如数组对象),多数虚拟机实现处于实现简单,存储高效的考虑,很可能是会要求连续的内存空间.
当堆无法再扩展时抛出oom异常
方法区
method aread:与堆一样,是各个线程共享的内存区域.用于存储被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存等数据.虽然《java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做非堆(non-heap)
,目的是与堆分开.
永久代:hotspot最初将方法区放在永久代中,但是容易导致某些方法(string.intern()
)导致永久代oom,jdk8以后废除了永久代,改用了元空间(meta-space,使用native memory).
《java虚拟机规范》堆方法区约束很宽松。甚至可以不实现垃圾收集。但并非意味着数据进入此区域就永久存在了,这区域的内存回收目标主要针对常量池的回收和对类型的卸载.一般来说对这个区域的回收效果难令人满意.
hotspot中的meta-space
元数据区(方法区)
元数据:最小的数据单位。元数据可以为数据说明其元素或属性(名称、大小、数据类型、等),或其结构(长度、字段、数据列),或其相关数据(位于何处、如何联系、拥有者)
元数据
Java classes在Java hotspot VM内部表示为类元数据。
内容
- Klass metatSpace
- noKlass metaSpace
Klass MetaSpace
klass是我们熟知的class文件在jvm里的运行时数据结构,不过有点要提的是我们看到的类似A.class其实是存在heap里的,是java.lang.Class的一个对象实例。
这块内存是紧接着Heap的,和我们之前的perm一样,这块内存大小可通过-XX:CompressedClassSpaceSize参数来控制,这个参数前面提到了默认是1G,但是这块内存也可以没有,假如没有开启压缩指针就不会有这块内存,这种情况下klass都会存在NoKlass Metaspace里,另外如果我们把-Xmx设置大于32G的话,其实也是没有这块内存的,因为会这么大内存会关闭压缩指针开关。还有就是这块内存最多只会存在No-Klass MetaSpace
NoKlass Metaspace专门来存klass相关的其他的内容,比如method,constantPool等,这块内存是由多块内存组合起来的,所以可以认为是不连续的内存块组成的。这块内存是必须的,虽然叫做NoKlass Metaspace,但是也其实可以存klass的内容,上面已经提到了对应场景。
Klass Metaspace和NoKlass Mestaspace都是所有classloader共享的,所以类加载器们要分配内存,但是每个类加载器都有一个SpaceManager,来管理属于这个类加载的内存小块。如果Klass Metaspace用完了,那就会OOM了,不过一般情况下不会,NoKlass Mestaspace是由一块块内存慢慢组合起来的,在没有达到限制条件的情况下,会不断加长这条链,让它可以持续工作。
class的信息:
(1)类加载器引用(ClassLoader)
(2)运行时常量池:包含所有常量、字段引用、方法引用、属性
(3)字段数据:每个字段的名字、类型(如类的全路径名、类型或接口) 、修饰符(如public、abstract、final)、属性
(4)方法数据:每个方法的名字、返回类型、参数类型(按顺序)、修饰符、属性
(5)方法代码:每个方法的字节码、操作数栈大小、局部变量大小、局部变量表、异常表和每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
参数
-XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize,最大空间,默认是没有限制的。 除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
-XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
-XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集
-verbose参数是为了获取类型加载和卸载的信息
meta space 工具
- jmap -clstats 它用来打印Java堆的类加载器的统计数据。对每一个类加载器,会输出它的名字,是否存活,地址,父类加载器,以及它已经加载的类的数量及大小。除此之外,驻留的字符串(intern)的数量及大小也会打印出来。
- jstat -gc,这个命令输出的是元空间的信息而非持久代的
- jcmd pid GC.class_stats提供类元数据大小的详细信息。使用这个功能启动程序时需要加上-XX:+UnlockDiagnosticVMOptions选项。
提高GC的性能
- Full GC中,元数据指向元数据的那些指针都不用再扫描了。很多复杂的元数据扫描的代码(尤其是CMS里面的那些)都删除了。
- 元空间只有少量的指针指向Java堆。这包括:类的元数据中指向java/lang/Class实例的指针;数组类的元数据中,指向java/lang/Class集合的指针。
- 没有元数据压缩的开销
- 减少了根对象的扫描(不再扫描虚拟机里面的已加载类的字典以及其它的内部哈希表)
- 减少了Full GC的时间
- G1回收器中,并发标记阶段完成后可以进行类的卸载
如果方法区无法满足新的内存分配时,抛出oom异常
运行时常量池
runtime constant pool: 方法区的一部分,clas文件中除了有类的版本,字段,方法接口等描述信息之外,还有一项信息时常量池表(constant pool table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法去的运行时常量池中.
当常量池无法再申请倒内存时会抛出oom异常
直接内存
NIO中可以使用native函数直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer
对象作为这块内存的引用进行操作.在一些场景中可以显著提升性能(避免了在java堆和native内存中来回复制数据)
动态扩展时也会出现oom异常(各个内存区域总和大于物理机内存)
hotspot虚拟机对象
对象的创建
- 检查指令的参数能否能在常量池中定位到一个类的符号引用.并且检查这个符号引用代表的类是否已经被加载解析和初始化过,若没有必须先执行类加载过程.
- 类加载检查过后,接下来jvm将为新生对象分配内存,对象所需内存的大小在类加载完毕后可以完全确定.然后进行内存分配,选择哪种分配方式,由jvm堆中内存是否工整决定,是否规整由采用的垃圾收集器是否带有压缩功能(compact)能力决定.
- 内存分配完成之后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为0值,如果使用了TLAB,这一项工作也会提前至TLAB分配时顺便进行.这步操作保证了对象的实例字段在java代码中可以不赋初始值就可以使用,使程序能访问到这些字段的数据类型所对应的零值.
- 接下来,java虚拟机还要对对象进行必要的设置,如这个对象是哪个类的实例.如何才能找对象的元数据信息,对象的哈希码(实际调用Object::hashcode()方法时才计算),对象的GC年龄代设置.这些信息存储在对象的对象头中.
- 此使从jvm的角度看,此时,一个对象已经创建完了,但是从程序的角度看,创建才刚刚开始-构造函数,class文件中的init方法还没执行,所有字段都为默认的零值,对象需要的其他资源和状态信息也没有按照预定的意图构造好,一般来说new指令之后会接着执行init方法,按照程序员的意图对对象进行初始化.
- 分配内存时的并发问题:给对象a分配内存,指针还未来得及修改,对象b同时使用了原来的指针分配内存:
- 对分配内存空间的动作进行同步处理(jvm采用了cas配上失败重试的方案保证更新操作的原子性)
- 内存分配的动作按照线程划分在不同空间之中进行,即每个线程在java堆中预先分配一小块内存(thread local allocation buffer,TLAB),只有本地线程缓冲区用完了,才会使用同步方法.
内存分配方式
指针碰撞-bump pointer
假设java堆中的内存是绝对规整的,使用过的内存被放置在一边,空闲内存在另一边,中间放着一个指针作为分界点指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离.空闲列表-free list
如果内存不是规整的, 使用未使用的相互交错,jvm就必须维护一个列表,记录上哪些内存是可用的,再分配的时候,从列表中找到一块足够大的空间划分给对象,并更新列表的记录.
对象的内存布局
主要分为三个部分,对象头,实例数据,对齐填充.
对象头
对象头包含两类信息:- 用于存储对象自身运行时数据,mark word
hotspot虚拟机对象头 mark word
存储内容 标志位 状态 对象哈希码,对象分代年龄 01 未锁定 偏向线程id 01 可偏向 指向锁记录的指针 00 轻量级锁定 指向重量级锁的指针 10 膨胀(重量级锁定) 空 11 gc标志 - 第二部分是类型指针,对象指向它的类型元数据的指针
- 用于存储对象自身运行时数据,mark word
实例数据
对齐数据
hotspot虚拟机自动内存管理要求任何对象的大小都是8的整数倍.
对象的访问定位
<java虚拟机规范>中只规定了reference类型是一个对象的引用,关于如何定位,访问对象在堆中的具体定位,由虚拟机实现.主流有两种方式
- 句柄访问:java堆中可能会划分出来一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中也包含了对象实例数据与类型数据各自具体的地址信息.
- 直接指针:java堆中的内存布局必须考虑如何放置访问类型数据的相关数据,reference中存储的直接就是对象地址.
hotspot使用的是第二种.(使用Shenandoah收集器会有一次额外的转发),实际使用句柄的方式也很常见
oom异常
垃圾收集器与内存分配策略
- 哪些内存需要回收
- 什么时候回收
- 如何回收
对象存活的判断
- 引用计数(java未使用此方式)
有大量额外情况,必须配合大量额外的处理,如循环依赖 - 对象可达性分析,通过
gc roots
无法到达,则判定已死
gc roots对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被被调用的方法堆栈中使用到的参数,局部变量,临时变量.
- 方法区中类静态属性引用的对象,如java类的引用类型静态变量.
- 方法区常量引用的对象,如字符串常量池(String table)里得引用
- 本地方法栈JNI引用的对象
- jvm虚拟机内部的引用,基本数据类型对应的Class对象,常驻的异常对象(NullPointerException,OutOfMemoryError)
- 所有被同步锁(synchronized关键字)持有的对象.
- 反应java虚拟机内部情况的JMXBean,JVMTI中注册表的回调
根据不同的gc器,以及针对不同区域进行垃圾回收时,也会有其他对象临时加入gc roots中.
引用
- 强引用
- 软引用(
soft reference
): 还有用,但非必须的对象.在将发生oom时,会把这些对象列进回收范围内进行二次回收.还不够,会发生oom - 弱引用: 发生垃圾收集时,无论内存是否足够,都会被回收
- 虚引用:无法通过一个虚引用获取一个对象实例,为一个对象设置虚引用的唯一目的只是为了能在这个对象被垃圾回收时得到一个系统通知.
finalize
对象的死亡要经历两次标记过程:
进行可达性分析后,发现没有与gc roots
链接的引用链,将会被第一次标记,如果没有覆盖finalize方法或者finalize方法已经被调用过,该对象将被放置为F-queue
的队列中.finalize方法只会执行一次
不建议复写此方法
回收方法区
<java虚拟机规范>可以不要求虚拟机在方法区中实现垃圾收集(事实上也确实存在未实现,或完整实现方法去类型卸载的收集器,jdk11的zgc收集器就不支持类卸载)
方法区的垃圾收集主要包含两部分内容:
- 废弃的常量
- 不再使用的类型:
- 所有实例都已被回收
- 加载该类的类加载器已经被回收.这个条件除非经过精心涉及的可替代类加载器的场景,OSGI,JSP的重加载等,否则很难达成
- 该类对应的java.lang.Class对象没有在任何地方被引用.无法在任何地方通过反射访问该类的方法.
- Java虚拟机被允许对满足上述三个条件的无用类进行回收, 这里说的仅仅是“被允许”, 而并不是和对象一样, 没有引用了就必然会回收。 关于是否要对类型进行回收, HotSpot虚拟机提供了-Xnoclassgc参数进行控制, 还可以使用-verbose: class以及-XX: +TraceClass-Loading、 -XX:+TraceClassUnLoading查看类加载和卸载信息, 其中-verbose: class和-XX: +TraceClassLoading可以在Product版的虚拟机中使用, -XX: +TraceClassUnLoading参数需要FastDebug版[1]的虚拟机支持
垃圾收集算法
从如何判定对象消亡的角度出发,垃圾收集算法分为引用计数式垃圾收集(reference countinggc)
和追踪式垃圾收集(Tracing GC)
两大类.这两类也常被称作”直接垃圾收集”和”间接垃圾收集”
分代收集理论
当前商业虚拟机的垃圾收集器,大多数遵循了分代收集generational collection
的理论进行设计.实质是一套符合大多数程序运行实际情况的经验法则, 它建立在两个分
代假说之上:
- 弱分代假说(Weak Generational Hypothesis) : 绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis) : 熬过越多次垃圾收集过程的对象就越难以消
亡。 - (由于跨代问题,增加了第三条经验法则)跨代引用假说(Intergenerational Reference Hypothesis) : 跨代引用相对同代引用仅占极少数.(依据这条假说, 我们就不应再为了少量的跨代引用去扫描整个老年代, 也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用, 只需在新生代上建立一个全局的数据结构( 该结构被称为“记忆集”, Remembered Set) , 这个结构把老年代划分成若干小块, 标识出老年代的哪一块内存会存在跨代引用。 此后当发生Minor GC时, 只有包含了跨代引用的小块内存里的对象才会被加入到
GC Roots
进行扫描。 虽然这种方法需要在对象改变引用关系( 如将自己或者某个属性赋值) 时维护记录数据的正确性, 会增加一些运行时的开销, 但比起收集时扫描整个老年代来说仍然是划算的。)
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则: 收集器应该将Java堆划分
出不同的区域, 然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数) 分配到不同的区域之中存储。
如果一个区域中大多数对象都是朝生夕灭, 难以熬过垃圾收集过程的话, 那
么把它们集中放在一起, 每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对
象, 就能以较低代价回收到大量的空间; 如果剩下的都是难以消亡的对象, 那把它们集中放在一块,
虚拟机便可以使用较低的频率来回收这个区域, 这就同时兼顾了垃圾收集的时间开销和内存的空间有
效利用。
在java堆中划分了不同区域之后,垃圾收集器才可以之回收某个或其中部分区域,因而才有了”minor gc,major gc, full gc”这样的回收类型的划分;也才能给针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法,因而发展出了标记-复制,标记清除,标记整理等针对性的垃圾收集
- 部分收集(partial gc): 指目标不是完整收集整个java堆的垃圾收集,其中分为:
- 新生代收集(minor gc/young gc):目标只是新生代的垃圾收集
- 老年代收集(major gc/old gc):目标只是老年代的垃圾收集.目前只有CMS收集器会单独收集老年代的行为.
- 混合收集(mixed gc):目标是收集整个新生代以及部分老年代的垃圾收集.只有G1收集器有这种行为
- 整堆收集(full gc): 收集整个java堆和方法区的垃圾收集.
标记清除算法
标记-清除(mark-sweep)算法分为“标记”和“清除”两个阶段: 首先标记出所有需要回
收的对象, 在标记完成后, 统一回收掉所有被标记的对象, 也可以反过来, 标记存活的对象, 统一回收所有未被标记的对象。 标记过程就是对象是否属于垃圾的判定过程
缺陷:
- 执行效率不稳定,随着对象增多,导致
标记,清除
过程事件和执行效率降低. - 空间碎片化,标记清除之后会产生大量不连续的内存碎片.
标记复制算法
它将可用内存按容量划分为大小相等的两块, 每次只使用其中的一块。 当这一块的内存用完了, 就将还存活着的对象复制到另外一块上面, 然后再把已使用过的内存空间一次清理掉(缺点浪费空间).现在商用虚拟机大多采用这种收集算法收集新生代(IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。 因此并不需要按照1∶ 1的比例来划分新生代的内存空间。).
解决了标记清除算法面对大量可回收对象执行效率低的问题.
Andrew Appel针对具备“朝生夕灭”特点的对象, 提出了一种更优化的半区复制分代策略, 现在称为“Appel式回收”。 HotSpot虚拟机的Serial、 ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局
Apple式内存布局:
分为80%的eden
和两个10%的survivor
区域.即每次新生代都会使用90%的空间,有10%的会被浪费,每次垃圾收集时,将eden和使用中的survivor中仍然存活的对象一次性复制到另外一块survivor空间.直接清理eden和使用的survivor.eden和survivor默认比例 8:1.如果剩余的survivor空间不足以容纳一次Minor gc之后存活的对象时,就需要依赖其他内存区域进行分配担保(handle promotion).
标记整理算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作, 效率将会降低。 更关键的是, 如果不想浪费50%的空间, 就需要有额外的空间进行分配担保, 以应对被使用的内存中所有对象都100%存活的极端情况, 所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,其中的标记过程仍然与“标记-清除”算法一样, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向内存空间一端移动, 然后直接清理掉边界以外的内存
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法, 而后者是移动式的。 是否移动回收后的存活对象是一项优缺点并存的风险决策:
- 如果移动存活对象,尤其老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这些操作将会暂停用户应用程序才能进行。
- 如果不移动和整理存活对象,将会导致空间碎片化问题,只能依赖更复杂的内存分配器和内存访问器来解决。内存的访问时用户程序最频繁的操作,在这个环节加额外操作,直接影响程序吞吐量。
基于以上两点, 是否移动对象都存在弊端, 移动则内存回收时会更复杂, 不移动则内存分配时会
更复杂。 从垃圾收集的停顿时间来看, 不移动对象停顿时间会更短, 甚至可以不需要停顿, 但是从整个程序的吞吐量来看(因内存分配和访问相比垃圾收集频率要高得多, 这部分的耗时增加, 总吞吐量仍然是下降的), 移动对象会更划算。
hotspot 虚拟机里关注吞吐量的Parallel Scavenge收集器基于标记-整理算法,关注延迟的CMS收集器基于标记-清除算法。
还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担, 做法是让虚拟机平时多数时间都采用标记-清除算法, 暂时容忍内存碎片的存在, 直到内存空间的碎片化程度已经大到影响对象分配时, 再采用标记-整理算法收集一次, 以获得规整的内存空间。 前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。
hotspot算法实现细节
根节点枚举
固定可作为根节点的主要在全局性的引用(常量或静态类属性)与执行上下文(栈帧中的本地变量表)中,尽管目标明确,但是要做到高效并不容易(光是方法区就有几百上千M)
可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,根节点的枚举始终还是必须在一个能保证一致性的快照中执行。
当程序停下来时,并不需要挨一个不漏的检查所有执行上下文和全局引用的位置,虚拟机应当有办法直接得到哪些地方存放对象引用
hotspot的解决方案里,使用了一组称为oopMap
的数据结构来达到此目的.一旦类加载动作完成,hotSpot就会把对象内什么偏移量是什么类型的数据计算出来.在即时编译过程中,也会在特定位置记录下栈里和寄存器里哪些位置是引用.
安全点
在oopMap的帮助下,gc roots的枚举变得容易,但是相对而言,改变引用(引起oopmap变化)的指令非常多,如果为每条指令都生成oopmap,将会需要大量的额外空间.空间成本也会无法承受.
实际上hotspot只有在特点的位置记录了这些信息,特点的位置被称为安全点(safe point)
安全点的选取特征:是否具有让程序长时间执行,长时间执行的特征:指令序列的复用.(方法调用,循环跳转,异常跳转)
对于安全点另外一个问题,如何让垃圾收集发生时,所有线程都跑到最近的安全点,然后停顿下来.有两种方案
- 抢先式中断(preemptive suspension):不需要线程执行代码主动配合,垃圾收集发生时,系统首先把所有用户现场全部中断,如果有用户现场中断的地方不在安全点上,就恢复这条线程,让其继续执行.一会再重新中断,直到跑到中断点.几乎没有虚拟机实现采用抢先式中断来暂停线程响应gc事件.
- 主动式中断(voluntary suspension):当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志位,各个线程执行过程去轮询这个标志,一旦发现中断标志为真,就自己在最近的安全点上主动终端挂起.轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象.(轮询操作极为高效,hotspot将其精简为一条汇编指令)
安全区域
线程阻塞或睡眠时,安全点的解决方案无法处理.引入安全区域(safe region)来解决
安全区域指某一段代码片段内,引用关系都不会发生变化(视为扩展拉伸了的安全点)
当用户线程执行到安全区域内的代码时,会标识自己已经进入安全区域,如果这段时间发生垃圾回收,则不必管这些已经声明自己在安全区域内的线程.当线程离开安全区域时,要检查虚拟机是否已经完成了根节点枚举.如果没有完成,则一直等待,直到收到可以离开安全区域的信号.
记忆集与卡表
记忆集(remember set): 记录从非收集区指向收集区域的指针集合的抽象数据结构.解决分代收集时,对象跨代引用的问题
集合的实现方案有多种(存储和维护成本不同):
- 字长精度:精确到机器字长(处理器寻址位数).该字包含跨代指针
- 对象精度:精确到一个对象,该对象包含跨代指针.
- 卡精度:精确到一块内存区域,该区域内有对象含有跨代指针.
卡精度:所指的是用一种称为卡表(card table)
的方式去实现记忆集,也是目前最常用的记忆集实现方式.
卡表最简单的实现方式可以只是一个字节数组(hotspot 也是这样做的),其内每个元素都对应着标识的内存区域中一块特定大小的内存块,这个内存块被称为卡页(card page)
,大小通常是2的N次幂,hotspot的卡页大小是2的9次幂,512字节.一个卡页的内存通常包含不止一个对象,只要卡页内有一个或多个对象的字段存在跨代指针,就将相应卡表的数组元素的值标识为1,称为这个元素变脏(dirty)
写屏障
使用记忆集可以减小gc roots的扫描范围
记忆集中卡表的维护通过写屏障
实现.可以理解成虚拟机层面堆赋值操作的aop.
应用写屏障,虚拟机会为所有赋值操作生成相应的指令,每次只要对引用进行了更新,都会产生额外的开销(这个开销与扫描整个老年代的代价比则低得多)
除了写屏障的开销, 高并发下还有伪共享问题.jdk7之后增肌了-XX:+UseCondCardMark
参数用来决定是否开启卡表更新的条件判断,开启会额外增加一次判断,但能够避免伪共享问题.是否打开根据实际运行情况衡量
并发下可达性分析
枚举gcroots的停顿时间相对短暂且固定(在各种优化和oopmap的帮助下,不会随着堆容量而增长了)
从gcroots继续往下遍历对象图时,这一步骤的停顿时间必定会与java堆容量直接成正比。
三色标记法:
- 白色:对象尚未被垃圾收集器访问过(扫描完后,仍然为白色则表是不可达)
- 黑色:对象已经被垃圾收集器访问过,且这个对象的所有引用也都已经被扫描过
- 灰色:对象已经被垃圾收集器方位过,但是对象上还至少存在一个引用没有被扫描
如果并发的与用户线程执行,则可能导致
- 原本该收集的对象存货(浮动垃圾,可以容忍)
- 该存活的被垃圾回收(不可容忍)
Wilson于1994年在理论上证明了当且仅当以下两个条件同时满足时,会产生对象消失:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
因此,我们要解决扫描时对象的消失问题只需破坏这两个条件的其中之一即可。
- 增量更新(Incremental update):破坏第一个条件,黑色对象插入新的指向白色对象的引用关系时,将这个新插入的引用记录下来,等待扫描结束后,再将这些记录过的引用关系中黑色对象重新扫描一次。
- 原始快照(Snapshot at begining,satb):破坏第二个条件,当灰色对象要删除指向白色对象的引用时,就将这个要删除的引用记录下来,并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描。