Java 内存模型

188人浏览 / 0人评论

Java 内存模型

1 Java内存模型

java运行时内存分布

Java 内存模型

 

针对上面每一块进行详细的说明

Java 内存模型

 

其中虚拟机栈,本地方法栈,程序计数器是和当前线程相关。线程创建时,相应的区域分配内存,线程销毁时,释放相应内存

从上面可以看出JVM内存主要是分两块。一块是栈内存,一块是堆内存。个人理解,这么做主要是栈内存用于存储和指令执行先关的数据,堆内存存储的对象信息。两类数据的使用频率,生命周期都不相同。栈中数据使用完即可销毁。堆中对象可能需要频繁使用,且存活时间相对较长。

2 哪些对象是垃圾

堆内存中是存储对应的实例对象,如果不去管具体的JVM实现,我们自己去想怎么实现:

1 在堆内存中需要创建对象的时候就在堆内存空间中申请一块地址。后面有新的对象创建走后面的内存地址往后申请对应大小的空间

其实JVM也是这么操作的。但是上面还是有写问题的:

内存空间中对象申请的空间是连续的。如果连续的空间中某一个对象已经不需要使用,需要回收。那这个在申请的对象还是在空间地址后继续追加申请,回收的空间无法再被使用

内存空间的大小就这么大,如果都用完了就直接申请新的空间来说会用,对于已经回收的空间不做处理,现有的堆内存肯定无法使用。

那么应该如何对现有的数据进行资源的回收,将已经回收的对象空间释放出来。那么这里需要先讨论一个问题:

1 哪些对象是需要被回收并且释放空间的

对象创建在堆内存中,是否需要被回收,那么需要对现有的对象进行标记,标记对象。那么如何标记引用:

1 对象见使用过引用句柄来操作的,是否可以通过被引用的次数来标记是否可以被回收

这个标记的过程是标记引用

2.1 引用计数

标记引用是每一个实例对象被引用一次就增加一次引用计数,每减少一次引用,引用计数就减少一次。当对象的引用为0时,该对象可以被回收了。

引用计数是统计对象被引用的次数,这个存在一个无法解决的问题:

A对象引用B对象,B对象也引用了A对象

通过上面的这种A,B相互引用导致的引用计数的值都为1。从而无法被回收。

2.2 路径可达

引用计数无法解决循环依赖的问题,鉴于此,是否可以通过其他的方式来表达某个对象没有在被引用过。所有的对象基本都是创建在堆上的,是否可以通过其他的内存空间中的数据引用来标记堆中对象。所以又有一个相对应的数据算法:

通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时(即从 GC Roots 节点到该节点不可达),则证明该对象是不可用的

Java 内存模型

 

路径可达算法中使用到了GC Root。这个可作为GC Root的对象有:

1 虚拟机栈帧中的本地变量表

2 方法区中的静态属性的引用对象

3 方法区中的常量引用对象

4 本地方法栈中医用的对象

2.3 安全点和安全区域

在路径可达中通过GC Root对象来标记类是否可回收。所以首先需要找到所有的GC Root对象。GC Root对象基本上是在方法区中,但是现在很多的应用方法区都很大,这种如果全部遍历一遍话,耗时也是非常严重的,同时在遍历的过程中,可能紧接着对象的引用就发生了变化。

因此在GC时,必须停止所有的java线程。同样的如果停止了所有的java线程,那么GC的时间必须短,效率要高。那么从这个可以看出这是有一个问题:

1 时间要短

从时间短上,最有效的办法是拿空间换时间。在HotStop虚拟机中采用了一组OopMap的数据结构来标记类型信息。OopMap结构记录了在对象内什么偏移量上是什么类型的数据,所以从对象开始向外的扫描可以是准确的;这些数据是在类加载过程中计算得到的

oopMap就是一个附加的信息,告诉你栈上哪个位置本来是个什么东西。 这个信息是在JIT编译时跟机器码一起产生的。因为只有编译器知道源代码跟产生的代码的对应关系。 每个方法可能会有好几个oopMap,就是根据safepoint把一个方法的代码分成几段,每一段代码一个oopMap,作用域自然也仅限于这一段代码。 循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置。那这段代码的oopMap就会包含多条记录。

每个被JIT编译过后的方法也会在一些特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样GC在扫描栈的时候就会查询这些OopMap就知道哪里是引用了。这些特定的位置主要在:

1、循环的末尾

2、方法临返回前 / 调用方法的call指令后

3、可能抛异常的位置

这种位置被称为“安全点”(safepoint)。之所以要选择一些特定的位置来记录OopMap,是因为如果对每条指令(的位置)都记录OopMap的话,这些记录就会比较大,那么空间开销会显得不值得。选用一些比较关键的点来记录就能有效的缩小需要记录的数据量,但仍然能达到区分引用的目的。因为这样,HotSpot中GC不是在任意位置都可以进入,而只能在safepoint处进入。

安全点的选取需要注意:安全点选定太少,GC等待时间就太长,选的太多,GC就过于频繁

现在的问题是在Safe Point让线程们以怎样的机制中断,方案有两种:抢先式中断、主动式中断。

抢先式中断:GC发生时,中断所有线程,如果发现有线程不再安全点上,就恢复线程让它运行到安全点上。现在几乎不用这种方案。

主动式中断:设置一个标志,和安全点重合,再加上创建对象分配内存的地方。各个线程主动轮询这个标志,发现中断标志为真就挂起自己。HotSpot使用主动式中断。

安全区域是指在一段代码片段中,引用关系不会发生变化,在该区域的任何地方发生GC都是安全的。当代码执行到安全区域时,首先标识自己已经进入了安全区域,那样如果在这段时间里JVM发起GC,就不用管标示自己在安全区域的那些线程了,在线程离开安全区域时,会检查系统是否正在执行GC,如果是,就等到GC完成后再离开安全区域。

3 如何回收垃圾

在明确了怎么方式可以判断出当前这个对象是否是垃圾,那么需要明确的就是:如何高效的进行垃圾回收。这里不止是回收垃圾,还需要高效运行。由于 Java 虚拟机规范并没有对如何实现垃圾收集器做出明确的规定,因此各个厂商的虚拟机可以采用不同的方式来实现垃圾收集器,这里我们讨论几种常见的垃圾收集算法的核心思想。

3.1 标记清除算法

Java 内存模型

 

标记清除算法是最基础的一种垃圾回收算法,它主要分为两部分。首先对内存区域中的对象进行标记,标记出那些是需要被回收的。标记后,然后就是针对已经标记的内容数据进行清理。

如上图清理以后,清理后的空闲内存并不连续。如果这个时候要申请一块大的内存空间。空闲的连续的空间并不能满足需求。这种算法的确定是:

1 清理后内存空间不连续,碎片话的内存得不到有效的利用

3.2 复制算法

复制算法是基于标记清除算法演化来的。既然清除后的内存空间不连续,那么将内存整理一下就好。这个整理的方式比较简粗暴。将内存一切为二。标记以后,将存活的内存拷贝到另外一块内存中,将当期那使用的一半内存进行回收。

Java 内存模型

 

复制算法的缺点:明显降低了可用内存的大小,内存使用率不高

3.3 标记整理算法

标记整理算法整合了上面两种算法的基础思想。标记后将待回收的内存进行整理。整理过程不是一刀切将内存分为两份进行拷贝。而是将待回收的对象都移动到一端。然后在针对待回收对象进行回收并释放空间。标记整理是标记清除算法的一种升级,解决了内存的碎片化问题。同时也规避了复制算法仅能使用一半内存空间的问题。

Java 内存模型

 

根据图可以看出内存中被标记出需要回收对象统一移动到内存的一端准备被回收。同时整个内存空间中从第二个对象开始所有对象的引用地址都基本发生了变化。内存的变动相比较于其他的算法,变动更加频繁。而且对象在从空闲区域迁移到内存的一端,这里需要改动对象的引用指针,这个时候程序需要暂停执行。如果回收时间增加,程序暂停时间增加,这个是不可忍受的情况

3.4 内存分代模型

其实从以上的几种算法来看,标记整理算法是相对比较优的解决方案了。但是对于内存的频繁变动,是否有办法来规避掉或者是尽量减少因GC导致的内存变动。从标记整理的思路上来看,每一次GC时,都存活的对象移动到一块区域中去,就不管这一部分区域了。对于频繁变动的内存区域进行回收整理。但是想回来,一直存活的对象移动到一块区域中后,这块区域并不是无限大的,也是需要进行GC的。因此是否可以针对不同存活时间来对内存进行划分呢。现在在主流的商业虚拟机中均采用分代收集。

分代收集主要是根据对象的存活周期的不同将内存划分为新生代和老年代。新生代又分为Eden区,Survivor 区。新生代,老年代的空间占用如图:

Java 内存模型

 

3.4.1 Eden区

据网上资料,IBM公司专门研究表明,有将近98%的对象是朝生夕死。针对这种情况,大多数对象会在新生代的Eden区中进行分配,当Eden区内存空间不足时,虚拟机会发起一次Minor GC。MinorGC相比MajorGC更频繁,回收速度更快

通过MinorGC之后,Eden区会被清空,Eden中绝大多数对象都会被回收。而无需回收的对象,会直接进入到Survivor区中(Suvivor区一共有两个,进入其中一个Survivor区中)。若是其中一个Survivor区空间不够,则直接进入到Old区中。

3.4.2 Survivor区

Survivor区相当于一个缓冲区。Eden区中的对象不能再第一次MinorGC后,如果还存活就直接进入Old区。毕竟有些对象的存活时间并不长,可能经过两次或者三次MinorGC之后就消亡需要回收了。如果一次GC后直接进入Old区,这明显不够最优。毕竟经过IBM的研究表明大部分的对象存活周期是短暂的。

Survivor区起到了进一步筛选可进入Old区的存活对象。

Survivor区是有两个的。主要是为了解决内存碎片化问题。因为新生代中的对象98%都是朝生夕死,因此在回收时采用复制算法是效率最高的,毕竟存活的对象是少数的。采用复制算法的话,需要将内存空间进行分割为二,将其中一块中存活的对象拷贝到另外一块中,再将其中一块进行清空回收。清理效率极高。而且有效的减少了内存碎片话问题。

但是问题又来了,第一次MinorGC将存活对象都拷贝到其中一个Survivor区了。第二次MinorGC呢?这里的操作是两个Survivor区进行互换。第二次MinorGC时,将存活对象拷贝到上一次回收清空的Survivor区,然后将当前Survivor区进行清空回收。因此整个过程有一个Survivor区是空的,一个Survivor区是无碎片的。

3.4.3 Old区

老年代占据着2/3的内存空间,只有字MajorGC的时候才会进行清理。每次GC都会触发上面说道的线程暂停。内存暂用的越大,线程暂停等待的时间越长。因此内存越大对于系统来说并不是越好。老年代中的对象一般情况下存活时间都是相对较长的。如果使用复制算法来进行垃圾回收,效率很低。

老年代一般情况下采用标记整理算法来进行垃圾回收。

从Survivor区中可以看到对象是会在两个Survivor区中反复。如果存活就从一个Survivor区中拷贝复制到另外一个Survivor区中。那么到底存活多长时间会进入到Old区中呢?

JVM中给你每个对象都定义了一个对象年两计数器,正常情况下,对象不断的在Survivor区中移动,没经过一次MinorGC,对象的存活年龄增加1岁。当年龄增加到15岁以时,就会被移动到老年代。这个对象年龄可以通过-XX:MaxTenuringThreshold来设置。

那么是否是必须要经历15次MinorGC才会进入到老年代,虚拟机为了更好的适应不同程序的内存状况,并不会要求对象的年龄必须达到MaxTenuringThreshold才能晋升到老年代。

如果在Survivor区中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入到老年代,无需等到MaxTenuringThreshold。

除了上面说到的以外,对象是否可以直接进入到老年代中?这个是可以的,大对象直接进入老年代。大对象指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在 Eden 区及 2 个 Survivor 区之间发生大量的内存复制。针对这种大对象,虚拟机提供了-XX:PretenureSizeThreshold参数来设置,令大于这个设置值的对象直接进入老年代中,避免在Eden区和两个Survivor区之间发生大量的内存复制

3.4.3 空间分配担保

在MinorGC发生之前,虚拟机会先检查老年代最大可用连续的空间是否大于新生代所有对象总空间,如果这个条件成立,那么MinorGC是可以确保是安全的,如果不大于的话,JVM就需要判断HandlePromotionFailure是否允许空间分配担保。

如果HandlePromotionFailure设置为true,允许空间分配担保,那么JVM会继续检查老年代最大的可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,这正常进行一次MinorGC。这个操作是有风险的,因为计算的是平均值,如果某一时刻出现对象激增,就会造成晋升的对象比平均值要大。

如果HandlePromotionFailure设置为false,表示不允许进行空间分配担保,则进行一次MajorGC。

3.4.5 永久代和元空间

方法区与堆空间相同,是各个线程共享的内存区域。存储的信息基本是类信息,常量,静态变量,及时编辑器编译后的代码等数据。方法区一般叫做非堆内存,或者是永久代。

在JDK8中,采用元空间来代替永久代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。

去除永久代的主要原因是:

1 为了HotSpot与JRockit的融合

2 永久代大小不容易确定,PermSize指定太小容易造成永久代OOM

默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:

-XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。

  -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。

  除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:

  -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集

  -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

在 JDK 8下不再指定 PermSize 和 MaxPermSize。而是指定 MetaSpaceSize 和 MaxMetaSpaceSize的大小

全部评论