JVM内存

1.虚拟机运行过程

从虚拟机视角来看,执行Java代码首先需要将它编译而成的class文件加载到Java虚拟机中。加载后的Java类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法区中的代码。

Java虚拟机将运行时内存区域划分为五个部分,分别为方法区、堆、PC寄存器、Java方法栈和本地方法栈。Java程序编译而成的class文件先加载到方法区中,方能在Java虚拟机中运行。

Java虚拟机会将栈细分为面向Java方法的方法栈,面向本地方法(用C++写的native方法)的本地方法栈,以及存放各个线程执行位置的PC寄存器

在运行过程中,每当调用进入一个Java方法,Java虚拟机会在当前线程的Java方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且Java虚拟机不要求栈帧在内存空间中连续分布。当退出当前执行的方法时,不管是正常返回还是异常返回,Java虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。

从硬件视角来看,Java字节码无法直接执行。因此,Java虚拟机需要将字节码翻译成机器码。在HotSpot里面,上述翻译过程有两种形式:第一种是解释执行,即逐条将字节码翻译成机器码并执行;第二种是即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。

前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快。HotSpot默认采用混合模式,综合了解解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。

2.JVM内存模型

2.1内存分布

  • Java堆

Java堆在虚拟机启动的时候建立,它是Java程序最主要的内存工作区域。几乎所有的Java对象实例都存放在Java堆中。堆空间是所有线程共享的,这是一块与Java应用密切相关的内存空间。GC主要关注的就是这块空间的回收。

  • PC寄存器

PC(Program Counter)寄存器也是每一个线程私有的空间,Java虚拟机会为每一个Java线程创建PC寄存器。在任意时刻,一个Java线程总是在执行一个方法,这个正在被执行的指令,如果当前方法是本地方法,那么PC寄存器的值就是undefined

  • Java方法栈

每一个Java虚拟机线程都有一个私有的Java栈,一个线程的Java栈在线程创建的时候被创建,Java栈中保存着帧信息,Java栈中保存着局部变量、方法参数,同时和Java方法的调用、返回密切相关。

  • 本地方法栈

本地方法栈和Java栈非常类似,最大的不同在于Java栈用于方法的调用,而本地方法栈则用于本地方法的调用,作为对Java虚拟机的重要扩展,Java虚拟机允许Java直接调用本地方法(通常使用C、C++编写)

  • 直接内存

Java的NIO库允许Java程序使用直接内存。直接内存是在Java堆外的、直接向系统申请的内存空间。通常访问直接内存的速度会优于Java堆。因此出于性能的考虑,读写频繁的场合可能会考虑使用直接内存。由于直接内存在Java堆外,因此它的大小不会直接受限于Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。

  • 元数据空间

与Java堆一样,是各个线程共用的内存区域,存储类加载子系统加载的类信息、静态变量、运行时常量池、即时编译编译后的本地代码,更通用的名称应该叫方法区。JDK1.8前这个区域叫永久代,用的是堆空间,JDK1.8该区域使用直接内存,默认大小不限。

JDK7之前的HotSpot,字符串常量池的字符串被存储在永久代中,因此可能导致一系列的性能问题和内存溢出错误。在JDK8中,字符串常量池中只保存字符串的引用。

  1. Code Cache:即时编译器编译的本地代码

  2. compresses class space:类元数据,也就是Class对象放的地方,如果开启了类型指针压缩才会在元数据空间隔离这个区域,否则直接在元数据空间存放信息。

2.2 对象内存

在Java虚拟机中,每个Java对象都有一个对象头(object header),这个由标记字段和类型指针所构成。其中,标记字段用以存储Java虚拟机有关该对象的运行数据,如哈希码、GC信息以及锁信息,而类型指针则指向该对象的类。

在64位的Java虚拟机中,对象头的标记字段占64位,而类型指针又占了64位。也就是说,每一个Java对象在内存中的额外开销就是16个字节。以Integer类为例,它仅有一个int类型的私有字段,占4个字节。因此,每一个Integer对象的额外内存开销至少是400%,这也是为什么Java要引入基本类型的原因之一。

为了尽量较少对象的内存使用量,64位Java虚拟机引入了压缩指针的概念(对应虚拟机选项-XX:+UseCompressedOops,默认开启),将堆中原本64位的Java对象压缩成32位的。这样一来,对象头中的类型指针也会被压缩成32位,使得对象头的大小从16字节降至12字节。当然,压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引用类型数组。

压缩指针的实现方式,通过对字节进行分组从而使用少量地址指向更多空间,默认8字节一组,如:32位的指针可以指向2的32次方个地址(4G),使用压缩指针后,还是可以指向2的32次方个地址,但是每个地址对应的是一个组(8个字节),从而可以管理的物理地址是2的32次方*8(32G)。如果内存超过32GB,则会关闭压缩指针。

字段重排列,就是Java虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。

相关配置参数(原本可能是8字节内存对齐,我们可以设置为4字节内存对齐,避免不满8字节,后面多了几个padding,造成内存浪费)

-XX:ObjectAlignmentInBytes=n:设置Java对象的内存对齐,默认是8字节,指定的值必须是2的幂,且必须在8和256之间,这个选项使得压缩指针成为可能,同时也会导致空间浪费,如:Class A(int i),i成员变量实际占用的是8个字节,当然,如果类中还有一个int类型的成员变量,则两个int成员变量共用8个字节。

-XX:+UseCompressedClassPointers:启用类型指针压缩,也就是对象头中的类型指针是否压缩。启用这个选项后,Metaspace的空间有一块会由CompressedClassSpaceSize限制,这个空间存储类元数据(各种类文件的Class实例),如果不启用类型指针压缩,类元数据还是放在Metaspace中,但是没有了CompressedClassSpaceSize的限制。CompressedClassSpaceSize默认为1G,可以通过-XX:CompressedClassSpaceSize=n设置,但是必须介于1048576和3221225472之间。

-XX:+UserCompressesOops:压缩对象指针,“oops”指的是普通对象指针(“ordinary”object pointers),如:Object o;不压缩的情况下,占用8字节,压缩占有4字节。

示例:

 class MyClass{
     long longFiled;
     int intFiled;
     Object refFiled1;
     Object refFiled2;
 }
  • -XX:-UserCompressesClassPointers -XX:-UserCompressedOops

    一个实例占48个字节,内存分布如下:

    标记字段:8

    类型指针:8

    longFiled: 8

    int: 8

    refFiled : 8

    refFiled2 : 8

  • -XX:+UserCompressesClassPointers -XX:-UserCompressedOops

    一个实例占48个字节,指针压缩关闭,内存分布如下:

    标记字段:8

    类型指针:8

    longFiled: 8

    int: 8

    refFiled : 8

    refFiled2 : 8

  • -XX:-UserCompressesClassPointers -XX:+UserCompressedOops

    一个实例占40个字节,指针压缩关闭,内存分布如下:

    标记字段:8

    类型指针:8

    longFiled: 8

    int: 8

    refFiled : 4#引用指针压缩

    refFiled2 : 4#引用指针

  • -XX:+UserCompressesClassPointers -XX:+UserCompressedOops

    一个实例占32个字节,指针压缩关闭,内存分布如下:

    标记字段:8

    类型指针:4#类型指针压缩

    longFiled: 8

    int: 4#字段重排列,使用类型指针空出的4个字节

    refFiled : 4#引用指针压缩

    refFiled2 : 4#引用指针压缩

3.垃圾回收

3.1 如何辨别垃圾

3.1.1 引用计数法

每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为0,则说明该对象已经死亡,便可以回收了。即:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器+1.如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器-1。

这个方法除了需要额外的空间来存储计数器,以及繁琐的计数器更新操作,引用计数法还有一个重大的漏洞,那便是无法处理循环引用的对象,从而造成内存泄漏。(A指向B,B指向A,没有引用再指向A和B,A和B是可以回收的,但是引用计数器都为1)。

3.1.2. 可达性分析

目前Java虚拟机的主流垃圾回收器采取的是可达性分析算法。这个算法的实质在于将一系列GCRoots作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。

所谓GC Roots可以暂时理解为由堆外指向堆内的引用,一般而言,GC Roots包括(但不限于)如下几种:

  1. Java方法栈帧中的局部变量

  2. 已加载类的静态变量

  3. JNI handles;

  4. 已启动且未停止的Java线程

可达性分析可以解决引用计数法所不能解决的循环引用问题。举例来说,即便对象a和b相互引用,只要从GC Roots出发无法到达a或者b,那么可达性分析便不会将他们加入存活对象合集之中。

但是这个方法也有问题,当垃圾回收线程进行可达性分析时,如果其他线程还在运行,就可能导致误报或者漏报。误报:垃圾回收线程进行可达性分析时发现a引用指向了某个对象,但是其他线程在可达性分析之后就将该引用置为了空,那么这个对象就在本次垃圾回收中不会被回收,得等到下次。漏报:垃圾回收线程进行可达性分析时发现a引用没有指向任何对象,但是其他线程在可达性分析之后将该引用执行了某个对象,那么那个对象就在本次垃圾回收中被回收了,当通过a引用调用这个对象时,JVM崩溃。

为了防止在标记过程中堆栈的状态发生改变,Java虚拟机采取安全点机制来实现Stop-the-world操作,暂停其它非垃圾回收线程。

3.2 如何回收堆空间

当标记完所有的存活对象时,我们便可以进行死亡对象的回收工作了。主流的基础回收方式可分为三种:

1.标记—清除(sweep),即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。

清除这种回收方式的原理及其简单,但是有两个缺点。一是会造成内存碎片。由于Java虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。另一个则是分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来分配。而对于空闲列表,Java虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。

2.标记-压缩(compact),即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。

3.标记—复制(copy),即把内存区域分为两等分,分别用两个指针from和to来维护,并且只是用from指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到to指针指向的内存区域中,并且交换from指针和to指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也及其明显,即堆空间的使用效率及其低下,此算法把内存空间划分为两个相等的区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域,把正在使用中的对象复制到另外一个区域中。此算法每次只处理正在使用中的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理,不会出现“碎片”问题。当然,此算法的缺点也是很明显的,就是需要两倍内存空间。

总结:

  • 清除:优点:不需要额外的空间,缺点:较长的GC暂停时间,较大的扫描时间开销(多遍历一次整个内存区域,把所有没有标记活跃的对象进行回收处理),产生较多的空间碎片;

  • 压缩:性能开销较大

  • 复制:优点:只访问活跃对象,将所有活动对象复制走之后就清空整个空间,不用去访问死对象,所以遍历空间的成本较小,缺点:需要巨大的复制成本和较多的内存;

现代的垃圾回收器往往会综合上述几种回收方式,综合他们的优点的同时规避他们的缺点。

3.3 堆结构及对象分代

对于大多数应用来说,对象的生存周期趋向于向两个极端集中,大部分的Java对象只存活一小段时间,而存活下来的小部分Java对象则会存活很长一段时间。

为了避免每次垃圾回收都处理(可达性分析、回收堆空间)所有对象,出现了Java虚拟机的分代回收思想。简单来说,就是将堆空间划分为两代,分别叫做新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长,则将其移动到老年代。

Java虚拟机可以给不同代使用不同的回收算法。对于新生代,我们猜测大部分的Java对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。

对于老年代,我们猜测大部分的垃圾已经在新生代被回收了。而在老年代中的对象有大概率会继续存活。当真正出发针对老年代的回收时,则代表这个假设出错了,或者堆的空间耗尽了。

这时候,Java虚拟机往往需要做一次全堆扫描,耗时也将不计成本。(当然,现代的垃圾回收器都在并发收集的道路上发展,来避免这种全堆扫描的情况。)

3.3.1 新生代空间

Java虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为Eden区,以及两个大小相同的Survivor区。

默认情况下,Java虚拟机采取的是一种动态分配的策略,根据生成对象的速率,以及Survivor区的使用情况动态调整Eden区和Survivor区的比例。当然,也可以通过参数-XX:SurvivorRatio来固定这个比例。但是需要注意的是,其中一个Survivor区一直为空,因此比例越低浪费的堆空间将越高。

通常来说,当我们调用new指令时,它会在Eden区中划出一块作为存储对象的内存。当Eden区的空间耗尽了的时候,Java虚拟机便会触发一次对新生代空间的垃圾回收(Minor GC),来收集新生代的垃圾。存活下来的对象,则会被送到Survivor区。新生代共有两个Survivor区,分别用from和to来指代。其中to指向的Survivor区是空的。当发生Minor GC时,Eden区和from指向的Survivor区中的存活对象会被复制到to指向的Survivor区中,然后交换from和to指针,以保证下一次Minor GC时,to指向的Survivor区还是空的。

Java虚拟机会记录Survivor区中的对象一共被来回复制了几次。如果一个对象被复制的次数为15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个Survivor区已经被占用50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

总而言之,当发生Minor GC时,我们应用了标记-复制算法,将Survivor区中的老存活对象晋升到老年代,然后将剩下的存活对象和Eden区的存活对象复制到另一个Survivor区中。理想情况下,Eden区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记-复制算法效果极好。

3.3.2 老年代空间

在新生代中经历了多次(具体看虚拟机配置的阈值)GC后仍然存活下来的对象会进入老年代中。老年代中的对象生命周期较长,存活率比较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢。主要实施标记清除或标记清除整理算法;

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值