概述
JVM
- Java 内存区域
- 虚拟机栈
- 栈帧的组成
- 动态分派
- 栈可能会引发的问题
- 基于栈的解释执行
- 本地方法栈
- 程序计数器
- 堆
- 分区
- 内存分配机制
- 方法区
- 元空间与永久区
- 运行时常量池
- 直接内存(堆外内存)
- 对象的生命周期
- 对象的创建
- 类加载检查
- 划分内存区域
- 划分方式
- 并发问题
- 初始化零值
- 设置对象头
- 初始化
- 对象的内存布局
- 对象的查找
- 判断对象是否死亡
- GC(对象的回收)
- GC回收算法
- GC分类
- GC收集器
- CMS的过程
- CMS的弊端
- G First的特点是什么
- HotSpot 算法实现细节
- 根节点枚举
- 跨代引用
Java 内存区域
了解 Java 的内存区域主要是为了在出现 OOM 问题时快速定位与解决问题,运行时数据区包含以下几个方面
虚拟机栈
每个线程都会有一个虚拟机栈、一个程序计数器、一个本地方法栈,因为 Java 的虚拟机大都和操作系统 CPU 一一对应,这线程就对应 CPU 的核心
所有的Java方法调用都是通过栈来实现的
在调用方法时,一个栈帧入栈,方法结束,栈帧出栈
对象可以在栈上分配内存,这种方式只能未出现逃逸时使用(对象只在方法内使用),编译器经过逃逸分析结果,可以将代码优化,如栈上分配、同步省略(锁消除)、标量替换(将一个对象替换为组成它的多个标量)
HotSpot 虚拟机无法修改栈的大小,不会出现 OOM
栈帧的组成
1,局部变量表:是一个数字数组,在编译时已经确定大小,基本单位是局部变量槽,它的作用是存放方法中编译期可知的局部变量、方法入参的值、以及对象引用,在生成该表之后,其大小不会改变(指的是槽的数目不会改变,因为不同的虚拟机实现槽的大小有不同的方式,有的用32位,有的用64位)
this:值得注意的是,局部变量表中的第一个存放的对象引用,就是该方法的实例对象,这也是为什么可以从方法内可以找到对象属性
在局部变量表里,32位以内的类型只占用一个slot(比如short、int类型包括returnAddress类型(指向了一条字节码指令的地址)),64位的类型(long和double)占两个slot
关于局部变量表的入参,如果入参是八大数据类型,会直接存放在栈帧中,毕竟这些数据类型不大,因此方法内该数据的变化不会影响到全局,如果是一个对象,则放在栈中的是对象的引用 reference,对对象的修改会影响到全局
可重用:如果当前线程计数器的值已经超过局部变量表中某个值的作用域的时候,那么这块区域就是可重用的,可以重新赋值。但是,这块表中的区域不会立马被回收,只有在下次访问局部变量表的该区域的时候,才会将这部分内容清理掉,也就是惰性删除。最直接的例子就是,如果在某个代码块结束之后立马进行gc,此时在代码块中定义的变量不会被回收掉
2,操作数栈:数组实现,在编译时已经确定大小,作为方法调用的中转站使用,用于存放该方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中
数据共享:虚拟机一般对该区域进行优化,将某个栈帧中的操作数栈与其他栈帧中的局部变量表形成一个重叠区域,以提高空间利用率
3,动态链接:在这个方法需要调用其他方法的时候会用到这个,准确的说是动态调用的时候(重写)会用到这个,它的作用是将符号引用变成直接引用,方法区中有一块运行时常量池,里面存放着所有属性与方法的引用
因为被调用的目标方法在编译期无法被确定下来,只能够在程序运行期将方法的符号引用转换为直接引用,这种引用转换的过程具备动态性,称为动态链接
由于符号引用是文字形式表示的引用关系,我们在运行时需要找到这个方法的本体。直接引用就是通过对符号引用进行解析,来获得真正的函数入口地址,也就是在运行的内存区域找到该方法字节码的起始位置
4,方法返回地址:存放了调用此方法的程序计数器的值
5,附加信息:虚拟机可以将一些规范里没有描述的信息添加进栈帧中
动态分派
比如动态分派就是在运行时才知道方法的直接引用的。比如hashmap 与 treemap 都有put 方法,虚拟机怎么知道我们要使用hashmap中的put还是AbstractMap中的put 方法呢?
private void justTest() {
Map<Integer, Integer> hashMap = new HashMap<>();
Map<Integer, Integer> treeMap = new TreeMap<>();
}
事实上在字节码生成的时候调用重写方法时会生成 invokevirtual 指令,该指令会干以下这些事
- 1、找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 2、如果在类型C中找到与常量中的描述符和简单名称相符合的方法,然后进行访问权限验证,如果验证通过则返回这个方法的直接引用,查找过程结束;如果验证不通过,则抛出java.lang.IllegalAccessError异常。
- 3、否则未找到,就按照继承关系从下往上依次对类型C的各个父类进行第2步的搜索和验证过程。
- 4、如果始终没有找到合适的方法,则跑出java.lang.AbstractMethodError异常
这个过程被称为动态分派,它是一种单分派(宗量只有调用方法的类型一种),虚拟机在执行这个指令的时候不会去一个个反复去搜索类型元数据,会使用一些优化手段,就是编译时在方法区生成一个虚方法表,表中存放着各个方法的实际入口地址(虚方法是允许被其子类重新定义的方法)
栈可能会引发的问题
1,StackOverFlowError:有的栈不能自动增长栈,比如 HotSpot 实现的虚拟机栈,如果调用过多方法,就会导致栈超限
2,OutOfMemoryError:栈可以动态增长,但是在调用过多方法同时申请不到内存时,会 OOM
同时,如果为栈分配过多空间,使用多线程时创建了很多栈也会出现 OOM 的问题,当不能减少线程数目时,可以减少栈的大小或者堆的大小,这种减少内存空间来解决内存溢出的思路相当有趣
基于栈的解释执行
先说一下虚拟机生成机器指令的发生,有两种:
1,解释执行,虚拟机读取class文件,读取后在运行时遇到方法时一遍读方法的指令一遍生成机器指令给电脑,电脑再去执行
2,编译执行,将一段程序直接翻译成机器码(对于C/C++这种非跨平台的语言)或者中间码(Java这种跨平台语言,需要JVM再将字节码编译成机器码)。编译执行是直接将所有语句都编译成了机器语言,并且保存成可执行的机器码。执行的时候,是直接进行执行机器语言,不需要再进行解释/编译。比如前端编译器与JIT
而解释执行一般有两种方式:
1,基于栈的解释执行:通过一个栈暂存需要计算的中间数,这种方式的特点是实现比较偏软件,偏算法。从理论上来说,这种方式比较慢一点,并且实现相同的功能会比基于寄存器的解释执行要多,现在计算机硬件都是寄存器就证明了这一点。优点则是由于偏算法实现,因此移植性比较强
2,基于寄存器的解释执行:通过一个栈存放指令、中间数等。快、指令少,一般需要带参数。同时因为寄存器由硬件直接提供,不可避免的需要受到硬件的约束
本地方法栈
使用native调用的语句,为了让java运行非java语言的程序,在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一
其他与虚拟机栈一模一样
程序计数器
是线程私有的,唯一一个不会出现OutOfMemoryError的内存区域(因为它不会创建新的对象,里面不会存放一些杂七杂八的东西),它唯一的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,如果看过class文件中的常量表,应该会很好理解
它的主要作用有:
1,为了得到下一条需要执行的字节码指令(让程序知道该线程运行到哪里了)
2,在上下文切换时,线程切换后能恢复到正确的执行位置
如果现在执行的是本地方法,该计时器的值应该为空 Undefined
堆
GC(垃圾回收)的主要区域,又称GC堆
它唯一的作用就是储存对象实例、字符串常量池(java8之后)、static 变量。《java虚拟机规范》中说,所有的对象实例以及数组都应该被分配到堆上。在今天看来,这句话不是绝对的(栈上分配)
堆在物理上可以是连续空间,也可以不连续。堆的大小可以固定,也可以在运行时按需扩展。对于一些大对象而言(比如数组),多数虚拟机为了方便,会要求存放在一个连续空间中
字符串常量池中保存的是字符串对象的引用,字符串对象的引用指向堆中的字符串对象
分区
新生代:又分伊甸园区和幸存者from区、幸存者to区
- 伊甸园区:对象新建时存放的区域
- 幸存者区:对象GC后没有被清除,加入这里,并且年龄加1,to区总是空的
老年代:一般来说,对象15(这个数字可以设置)次GC没有被回收掉之后,进入老年区
在JDK1.7之前还有一个永久代,JDK8版本之后永久代已被Metaspace(元空间)取代
注意,这些区域只不过是垃圾收集器的设计风格而已,而非虚拟机必须实现的内存布局
内存分配机制
对象不一定非要在新生代分配,甚至不用在堆上分配。对象的分配有以下的规则
- 优先在伊甸园区分配,当空间不足时会发生一次 minor GC,如果GC后空间还是不足,多出来的对象只能放到老年代去了
- 大对象直接放到老年代,比如数组或者长字符串什么的,这么做的目的是为了避免这些大对象在执行复制算法的时候产生大量性能消耗
- 活得久的进入老年代
- 在幸存者区小于或者等于某一年龄的对象大于幸存者区空间的一半,大于这个年龄的对象就可以直接进入老年代
- 空间分配担保机制,就是说如果老年代的最大连续内存空间大小大于新时代对象总和或者历次平均晋升空间大小,就会进行minor GC(因为可能所有的新时代对象GC后都进入老年代)否则进行full GC,担保失败不会产生任何坏处,这么做的目的只是为了避免频繁进行full GC
方法区
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的
它存储已被虚拟机加载的类信息、域信息(类中属性)、方法信息、存放编译期生成的各种字面量(Literal)和符号引用的运行时常量池、JIT缓存代码块、常量、静态变量、虚方法表等
元空间与永久区
都是方法区的实现,HotSpot以前使用在堆中的永久区,现在改为放在本地内存里的元空间
原因是堆中内存分配后无法修改,更容易触碰到最大内存上限,而本地内存中的空间与硬件配置有关,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小
元空间可以被GC,主要GC对象是类与常量;元空间也会触发OOM,原因可能是加载了大量的jar包或者tomcat部署了很多工程
运行时常量池
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表,用于存放编译期生成的各种字面量和符号引用(指向方法、属性等的引用),同时也会把翻译出来的直接引用也储存在这个池子中,这部分内容将在类加载后存放到方法区的运行时常量池中
运行时常量池还有一个特点是具有动态性,在运行时能将新的常量放入池中,比如说经常出现的 Integer 类型
直接内存(堆外内存)
直接内存就是不在虚拟机进程使用的内存,也叫堆外内存,不属于虚拟机运行时数据区的一部分,但是现在被频繁的使用,同时也容易出现 OOM
NIO、Netty等技术使用到了直接内存,原理是操作系统把数据从内核态复制到用户态才能使用,直接操作堆外内存避免了赋值消耗
为什么会出现 OOM?比如给虚拟机设置的内存过大,把堆外内存的空间抢走了,就会出现 OOM
对象的生命周期
对象的创建
在虚拟机层面如何创建一个对象呢?
类加载检查
首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程
划分内存区域
在类加载完成后,我们已经知道这个对象需要多少内存空间了,可以在堆中划分一块内存区域出来给这个对象
划分方式
1,碰撞指针:如果使用标记压缩算法,内存是完整的,将指针向后移动
2,空闲链表:内存空间不完整,使用链表来记录内存空间,找到可以足够存放的地方,并更新链表
并发问题
在开发的过程中会频繁的创建对象,如果两个线程同时划分了一块内存区域就会发生对象信息被胡乱糅合的情况,JVM会严格的控制内存划分
1,在新生区(伊甸园区)为每个线程划分一个的空间,空间未满时存放在这些空间里,这些空间叫 TLAB(线程私有的缓冲区)
2,私有空间满了,为了安全应该上锁并分配内存,虚拟机使用 CAS(乐观锁的一种实现)加上重试的方式来分配内存
乐观锁:希望操作完成后没有并发问题,如果有就重做或撤销操作
初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)
这一步保证了对象不赋值就可以被访问到,这个过程其实非常有用,比如在 Spring 中循环依赖的时候
设置对象头
对象头主要包括两类信息:MarkWord、Klass Pointer(类型指针)
MarkWord 中可能包括锁信息、hashcode(不同对象可以有相同的code)、分代年龄等。MarkWord 可以动态变化的,虚拟机读不同对象的 MarkWord 有不同的方式,类似与 Linux 中的 stat、mysql 里的页头,用一个相对较少的数据块,去管理一个相对较多的整体数据
Mark Word 在 32 位 JVM 中的长度是 32bit,在 64 位 JVM 中长度是 64bit,同时,Mark Word 在不同的锁状态下存储的内容不同。以下是一个 64 位 JVM 的 Mark Word
类型指针是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
jdk8 版本默认开启指针压缩的,在压缩之前类型指针占 8 字节,压缩之前只占 4 字节
另外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中无法确定数组的大小
初始化
初始化就是执行 init 方法,从虚拟机的视角来看,一个新的对象已经产生了,但从Java 程序的视角来看,还有很多方法没有执行(比如构造方法,在初始化之后才会执行)
1,在 new 对象之后,init 方法(构造方法)之前,虚拟机会为实例变量赋上类型初始值,常量附上定义的值(值必须为字面量或常量),也就是变量初始化过程
2,init 方法执行顺序为:父类 init -> 变量赋值 -> 自身的代码块 -> 自身的构造方法
// 这个想要在类实例化的时候进行赋值
private String nike = "nike";
// 构造方法
public InitClassStu(String name) {
this.nike = name;
}
// 静态方法
static {
nike = "1";
}
// 实例"{}" 在生成<init>构造器时优先于构造函数
{
nike = "1";
}
3,init 方法中的代码块与变量赋值都是由编译器按照从上到下的顺序执行的
对象的内存布局
- 对象头:运行时数据加类型指针,MarkWord 占8字节,类型指针占4字节,一共12字节
- 实例数据:包括此对象中有多少数据类型以及类型指针,如果对象有属性字段,则这里会有数据信息。如果对象无属性字段,则这里就不会有数据。根据字段类型的不同占不同的字节,例如 boolean 类型占1个字节,int类型占4个字节等等
- 对齐填充:虚拟机规定对象必须是 8 字节的倍数(对象头已经被设计成 8 字节的倍数)如果一个对象不满 8 字节,就必须使用对齐填充。如果没有达到则自动填充
为什么需要对齐填充呢?因为 64 位系统一次获取 64 位数据,32 位系统一次读取 32 位数据。对齐填充的目的是让字段只出现在同一 CPU 的缓存行中,缓存行为 8 字节。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。当缓存行被拆分的时候,我们寻找下一个对象就会变得比较困难
对象的查找
1,使用句柄:栈帧中的引用存放的是句柄地址,堆中会有专门存放句柄的区域,句柄中存放对象地址(放在堆)和类型地址(放在方法区)
使用句柄的方式虽然多使用了内存做储存,但是在垃圾回收堆中对象位置改变的时候,只会改变句柄中的指针,这样比较快
2,直接指针:动态链接指向堆中对象地址,对象中放置访问类型数据的相关信息以及对象的实例数据,目前 java 采用这种方式
使用直接指针访问速度更快,不用像句柄一样二次查找
判断对象是否死亡
- 哪些对象需要回收?
- 什么时候回收?
- 怎么回收?
1,引用计数法:在对象中添加一个引用计数器,使用一次该对象引用记录器加1,当引用失效,计数器减1,如果该对象引用计数器为0则通知回收器进行回收,缺点是如果对象相互调用,就不能判断
那何为引用失效?比如Integer a = new XXX,然后将这个a置为null,此时这个对象的引用就断掉了,根据这个思路可以引申出更好的算法
2,可达性分析算法:设置一些对象为GC Roots,GC Roots引用的对象与它们引用的对象称为可达对象,其他对象为不可达对象,不可达对象会被干掉,目前java采用这种方式
可作为 GC Roots 的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象(static)
- 方法区中常量引用的对象(final)
- 所有被同步锁持有的对象
那对象有没有复活甲呢?有!但对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要放入终结队列。否则对象会在这个队列中会进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收
GC(对象的回收)
GC回收算法
0,分代收集理论:根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法
这个理论主要建立在强分代假说与弱分代假说上,强即熬过越多次收集的对象就越难消亡,弱即大多数对象都是朝生夕死的
因此,收集器给出了一个思路,即分区域储存与回收
1,复制收集算法:将区域分为两部分,对存活的对象进行标记,之后复制到另一边,清空原来的空间。优点是没有内存碎片,如果垃圾比较多时运行高效,缺点是需要两倍的内存空间
2,标记回收算法:首先根据算法把所有存活的对象标记出来,将使用的标记出来,第二次,遍历整个堆对象将未标记的对象回收。因为需要遍历整个堆所以效率比较低,并且回收后会产生内存碎片。而这里的”回收“是指把未标记的内存区域记录在一个空闲链表中,事实上在进行分配时对老对象进行覆盖
3,标记压缩算法:同上,并将回收后的区域进行整理,是得碎片空间减少,相比于复制算法,内存利用率比较高。从效率上来说,比回收算法低,并且移动对象时会STW
还有一种奇怪的解决方案,比如先不进行压缩,等待内存碎片影响到对象分配的时候才进行压缩,CMS 就是采用这种方法
GC分类
主要分为两大类:
Partial GC:收集部分
- Young GC(Minor GC):伊甸园区满了会发生(幸存者区不会触发Young GC),收集整个新生区
- Old GC(Major GC):老年代满了会发生,只收集老年代,只有CMS使用
- Mixed GC:对整个新生代和部分老年代进行垃圾收集,只有G1使用
Full GC:收集整个堆,老年代、永久代空间不足时会触发(比如新生代的晋升对象大于老年代剩余容量的时候)、或者调用System.gc()时会触发(System.gc方法事实上调用了Runtime.getRuntime.gc方法,并且不是百分百会进行gc)
如果你听过major GC,那它通常是指Full GC,但是你一定要问他指的是Full GC还是Old GC,因为现在大家的理解挺五花八门的
GC收集器
当所有线程到达全局安全点(在这个时间点上没有正在执行的字节码)或者安全区域的时候,GC线程主动式中断并开始工作,jdk虽然有自己默认的收集器,但是我们需要具备在不同情况下使用合适的GC收集器的能力
GC主要注重两个性能指标,一个是吞吐量(吞吐量越大越好,注重吞吐量代表高效利用CPU处理能力,一般是在后台运算并且不需要和用户交互的程序需要提高吞吐量,吞吐量是用户线程执行时间/程序执行时间,高吞吐量意味着每次垃圾收集的时间会比较长),一个是暂停时间(越短越好,代表用户的体验,用户可能不会在意10次20ms的暂停,但是一定会注意一次200ms的暂停)
1,Serial:应用程序停止(STW"Stop The World"),GC单线程执行,使用复制算法,优点是只有单核时性能好、开销小
2,Serial Old:收集老年代,使用压缩算法
3,ParNew:应用程序停止,GC线程并行执行,使用复制算法
4,CMS:重视停顿时间,并行执行,使用清除算法,达到阈值会触发,如果GC失败有后续方案(Serial Old)
5,PS(Parallel Scavenge):重视吞吐量,使用复制算法,和ParNew差不多,但是使用的底层框架不一样
6,Parallel Old:收集老年代,使用压缩算法,在jdk8中,默认使用这两个组合
7,G1:注重低延迟下提高CPU性能的收集器,一般用在大容量内存的服务端,同时收集新生代和老年代,整体整理算法实现,局部复制算法实现
8,Shenandoah:Openjdk中的新生代GC
9,ZGC:实验性的性能较好的官方GC
在选择垃圾收集器的时候,应该考虑主机系统,Java版本,程序主要关注点是什么,具体情况具体分析
同时,在处理虚拟机内存问题的时候还需要会阅读虚拟机的日志,在java9之前读取日志的参数比较混乱,不过现在只要知道处理内存问题的时候知道要去找日志就行了
CMS的过程
CMS的核心思想就是增量收集算法,指收集垃圾和用户程序交互进行
1,STW,标记与GC Roots直接相连的对象,过程很快
2,开启其他线程与GC线程,找到所有有连接的对象
3,STW,由于上一步其他线程的执行,无法找到所有的对象,现在重新标记,由此修正浮动垃圾
4,开启其他线程,并且进行回收
CMS的弊端
1,使用清除算法,产生大量碎片空间
2,并发清理与标记时因为部分线程在GC,会减少程序执行的效率
3,并发标记阶段无法标记所有的浮动垃圾,因此会频繁触动CMS
由于每个弊端都是致命的,在jdk14中,CMS彻底废弃
G First的特点是什么
G1采用区域化分代式,核心思想是分区算法,将整个堆分为多个Region(区域),每个区域既可以是新生代,又可以是老年代或者Humingous(专门用来储存大对象)
可预测停顿时间模型指,G1在允许运行时间下收集优先链表中价值最大的区域,同时G1的期望暂停时间可以设定,但是设定过小会影响吞吐量
G1在region之间使用的是复制算法,从整体上看又是标记压缩算法
使用记忆集来确记录对象被其他不同区域的对象引用
HotSpot 算法实现细节
我们现在知道垃圾收集算法是概念,垃圾收集器是各种不同的实现,那 HotSpot 的具体实现细节是什么呢,它在对于一些不可避免的开销(比如STW)时是如何做的呢
根节点枚举
不同的收集器在根节点枚举阶段一定会 STW,就算有些收集器将时间较长的追踪引用的过程与用户线程一起并发执行也不可避免枚举,为了优化这一过程的时间,虚拟机想了很多办法
现在的问题是根节点枚举必须在一个保证一致性的快照中才能进行(因为线程的运行可能会修改引用),因此必须 STW,而且将每一个 GC Roots 遍历一遍时间过长
一是虚拟机用空间换时间,用一组称为 OopMap 的数据结构来存放所有的 GC Roots,比如在某个方法中引用了哪个对象,或者在静态变量被加载的时候,虚拟机会把这些东西直接放在 OopMap 中,这样就能直接拿到 GC Roots 了
但是由于改变 OopMap 的语句非常多,虚拟机适当的减少了这个过程,它设定了一个安全点,只有在这个安全点的时候才会修改 OopMap。而只有在循环、抛出异常、方法调用的时候才可能会生成这个安全点
二是优化让所有的线程都在一个一致性状态的时间消耗。这时候安全点就发挥作用了,只有在这个安全点的时候才会修改 OopMap,那在这个安全点中生成一致性快照也没问题,现在问题就变成了如何让所有线程跑到这个安全点上
正常的程序员会怎么解决?当需要GC时通知所有线程跑到最近的安全点上即可,这样有两种解法,一是直接中断所有线程,再让没有在安全点的线程恢复跑到安全点,这就叫抢先式中断,中断和恢复的代价还是有一点的
第二种方法是设置一个标志位,程序要中断时将此标志位改变,所有的线程在到达安全点时都会检查一次标志位,如果判断成功直接暂停,这就叫主动式中断,一般都使用这个
还有一个小小小问题,就是一些线程可能在sleep,此时它无法响应虚拟机,也没法到达安全点,那怎么办?解决方法是设置安全区域,在该区域时 OopMap 一定不会改变。当程序进入安全区域时,设置标识位直到离开,虚拟机看到这个标识位就不会去管这个线程了
跨代引用
跨代引用是指新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用,这样YGC时,为了找到年轻代中的存活对象,不得不遍历整个老年代;反之亦然。这种方案存在极大的性能浪费
记忆集就是用来记录跨代引用的表,为每一个区域(一块连续的内存空间)分配一个记忆集,通过引入记忆集避免遍历所有的区域
如果在该记忆集对应的对象有引用其他代的对象时,该记忆集就变脏(我们可能想到在对象修改引用的时候加一个AOP操作以修改记忆集,事实上虚拟机也是这么做的,这种操作叫写屏障)。使用一个数组来存放所有的记忆集,在其他区域进行垃圾收集的时候,对该记忆集数组进行遍历,找出脏的记忆集,对对应对象进行查找
在高并发场景下对卡表进行操作会有“伪共享问题”,当然这是偏底层的操作系统相关的问题,就是说在CPU中的缓存是以缓存行来存放数据的,如果有多个线程修改同一个缓存行,就会出现同步相关问题影响性能(底层已经牺牲了一些性能解决了修改丢失问题)
为了解决性能消耗,我们在AOP前再加一个操作,即先检查卡表标记,当前卡表为被任何其他线程标记时才标记卡表并且更新卡表。不过这也有性能消耗,是否使用这个功能要在实践中权衡利弊
最后
以上就是踏实往事为你收集整理的JVM 自动内存管理Java 内存区域对象的生命周期GC(对象的回收)的全部内容,希望文章能够帮你解决JVM 自动内存管理Java 内存区域对象的生命周期GC(对象的回收)所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复