全面学习JVM内存管理及GC机制(一)

一.为什么要学习这些?

Java GC(Garbage Collection垃圾回收机制)是Java不同C++/C的主要主要区别之一,作为Java开发者,随着GC机制的日益完善,不需要编写内存回收和垃圾清理的代码,不必担心内存溢出或者内存泄漏,这样大大降低了程序员开发时的难度,但是任何事物都是有利有弊的,虽然不必开发人员显示的分配内存和回收垃圾,但是会出现以下几点问题:
1.会不知不觉浪费了很多内存
2.JVM要花费很多时间进行内存回收
3.会出现内存泄漏问题。
因此作为一名合格的Java程序猿,要学会JVM内存管理及垃圾回收机制,这样可以排查内存溢出和泄漏问题,编写更高并发量和高性能的代码。

JVM内存空间管理

根据JVM规范,JVM把内存分为以下五个部分

  1. 方法区
  2. 堆区
  3. 本地方法栈
  4. 虚拟机栈
  5. 程序计数器

在这里插入图片描述其中的方法区和堆区是线程所共享的。

2.1方法区

在Hotspot虚拟机中,这块区域对应的是Permanent Generation(持久代),一般的,方法区上执行的垃圾收集是很少的,因此方法区又被称为持久代的原因之一,但这也不代表着在方法区上完全没有垃圾收集,其上的垃圾收集主要是针对常量池的内存回收和对已加载类的卸载。
根据JVM规范,当方法区使用内存超过分配大小时,会抛出OutOfMemory:PermGen Space异常。
方法区中存储哪些东西呢?
在这里插入图片描述依次看一下具体每项是什么意思:
1.类型信息

类型的全限定名
超类的全限定名
直接超接口的全限定名
类型标志(该类是类类型还是接口类型)
类的访问描述符(public、private、default、abstract、final、static)

2.类型的常量池
存放该类所用到的所有常量的有序集合,包括直接常量(如字符串,整数,浮点数的常量)和对其他类型,字段,方法的符号引用。
3.字段信息:
·字段修饰符(public,protect,private)
·字段的类型
·字段名称
4.方法信息
·方法修饰符
·方法返回类型
·方法名
·方法参数个数,类型,顺序
5.类变量
静态变量。
**运行时常量池(Runtime Constant Pool)**是方法区的一部分,用于存储编译期就生成的字面常量、符号引用、翻译出来的直接引用(符号引用就是编码是用字符串表示某个变量、接口的位置,直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译);运行时常量池除了存储编译期常量外,也可以存储在运行时间产生的常量,比如String类的intern()方法,作用是String维护了一个常量池,如果调用的字符“abc”已经在常量池中,则返回池中的字符串地址,否则,新建一个常量加入池中,并返回地址。

JVM方法区的相关参数,最小值:–XX:PermSize;最大值 --XX:MaxPermSize。

2.2堆区

堆区是理解JavaGC机制最重要的区域,堆在JVM所管理的内存区域中占据最大,堆区也是GC机制主要管理的区域,堆由所有线程共享,在虚拟机启动时创建,堆用来存储对象实例以及数组值,可以认为所有用new创建的对象都在此分配内存。

对于堆区大小,可以通过参数-Xms和-Xmx来控制,-Xms为JVM启动时申请的最新heap内存,默认为物理内存的1/64但小于1GB;-Xmx为JVM可申请的最大Heap内存,默认为物理内存的1/4但小于1GB,默认当剩余堆空间小于40%时,JVM会增大Heap到-Xmx大小,可通过-XX:MinHeapFreeRadio参数来控制这个比例;当空余堆内存大于70%时,JVM会减小Heap大小到-Xms指定大小,可通过-XX:MaxHeapFreeRatio来指定这个比例。对于系统而言,为了避免在运行期间频繁的调整Heap大小,我们通常将-Xms和-Xmx设置成一样。

为了让内存更加高校,使用分代管理方式管理堆。(后面会具体讲为什么)如下图所示:
在这里插入图片描述
年轻代(Young Generation)
对象在被创建时首先在年轻代进行内存分配,当年轻代需要回收的时候需要出发Minor GC(也称作Young GC).
年轻代由Eden Space和两块相同大小的Survivor Space(又称S0和S1)构成,可通过-Xmn参数来调整新生代大小,也可通过-XX:SurvivorRadio来调整Eden Space和Survivor Space大小。不同的GC方式会按不同的方式来按此值划分Eden Space和Survivor Space,有些GC方式还会根据运行状况来动态调整Eden、S0、S1的大小。
年轻代 中的Eden区内存分配是连续的,所以内存的分配和回收都很快(由于Eden区对象的存活时间都很短,采用复制回收算法,后面会详细讲解)。
如果在执行垃圾回收之后,仍没有足够的内存分配,也不能再扩展,将会抛出OutOfMemoryError:Java Heap Space异常。
老年代(Old Generation)
老年代用于存放在年轻代经过多次垃圾回收依然存活的对象,可以理解为比较老的对象,比如缓存对象,新建的对象也可以在老年代直接分配内存,这主要有两种情况,一种为大对象,可以通过启动参数设置-XX:PretenureSizeThreshold=1024,表示超过多大时就不在年轻代分配,而是直接在老年代分配。此参数在年轻代采用Parallel Scavenge GC时无效,因为其会根据运行情况自己决定什么对象直接在老年代上分配内存;另一种为大的数组对象,且数组对象中无引用外部对象。
当老年代满了的时候就需要对老年代进行垃圾回收,使用Major GC.
老年代所占用的内存大小为-Xmx对应的值减去-Xmn对应的值。

2.3本地方法栈

本地方法栈是用来支持native方法执行的,存储了每个native方法的调用状态,虚拟机栈和本地方法栈运行机制是一样的,唯一的不同是虚拟机栈java方法的,本地方法栈用来执行native方法。在HotSpot虚拟机中,会将本地方法栈与虚拟机栈放在一起使用。

2.4虚拟机栈

虚拟机栈占用的是操作系统的内存,每个线程都对应着一个虚拟机栈,是线程私有的,而且分配非常高效。一个线程的每一个方法在执行的时候都会创建一个栈帧,栈帧中存储的有局部变量表、操作数栈、动态链接、方法出口等,当方法被调用时,栈帧在JVM栈中入栈,执行完后出栈。在执行这个方法时,它使用这个帧来存储参数、局部变量、中间运算结果等数据。
JVM栈之局部变量表:
局部变量表中存放着方法的相关局部变量,包括包括各种基本数据类型,对象的引用,返回地址等。在局部变量表中,只有long和double类型会占用2个局部变量空间(Slot,对于32位机器,一个Slot就是32个bit),其它都是1个Slot。需要注意的是,局部变量表是在编译时就已经确定好的,方法运行所需要分配的空间在栈帧中是完全确定的,在方法的生命周期内都不会改变。举例如下:

public class StackDemo {
    
    //静态方法
    public static int runStatic(int i, long l, float f, Object o, byte b) {
        return 0;
    }
 
    //实例方法
    public int runInstance(char c, short s, boolean b) {
        return 0;
    }
 
}

在这里插入图片描述
上述静态方法和实例方法的局部变量表结构基本相同,唯一不同的是实例方法的第一个位置存放的是当前对象的引用。
JVM栈之操作数栈
Java没有寄存器,所有参数传递都是使用操作数栈。

public static int add(int a,int b){
        int c=0;
        c=a+b;
        return c;
    }

入栈出栈的步骤如下:

  1. 把局部变量0压栈
  2. 把局部变量1压栈
  3. 弹出两个变量求和,结果压栈
  4. 弹出结果存放于局部变量2
  5. 局部变量2压栈
  6. 返回

如果计算100+98的值,那么操作数栈的变化如下图所示:
在这里插入图片描述动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程的动态连接。

静态解析:常量池中的符号引用,一部分会在类加载阶段或第一是时间的时候就转化为直接引用。
动态连接:另一部分在每一个运行期间转化为直接引用,这个过程称作动态连接。
方法返回地址
方法退出的方式分两种:正常完成出口和异常完成出口
正常完成出口:执行引擎执行任意一个方法返回(如:return)的字节码指令,这时候会可能会有返回值返回值方法的调用者。
一般来说,正常退出时,调用者的PC计数器的值可以作为返回地址。
异常完成出口:在方法执行的过程中遇到了异常,且没有在方法体中进行处理。异常完成出口退出时,不会给上层调用者任何返回值。
方法退出实际上等同于当前栈帧出栈,因此一般过程为:

恢复上层方法的局部变量表和操作数栈。
把返回值压入调用者栈帧的操作数栈中
调整PC计数器的值以指向后面一条指令。

虚拟机栈中定义了两种异常,如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出StatckOverFlowError(栈溢出);不过多数Java虚拟机都允许动态扩展虚拟机栈的大小(有少部分是固定长度的),所以线程可以一直申请栈,直到内存不足,此时,会抛出OutOfMemoryError(内存溢出)。

2.5程序计数器

程序寄存器是一个比较小的内存区域,可能是一个cpu寄存器或者操作系统内存,其主要是用于指示当前指令的字节码执行到了哪行,字节码解释器在工作时会改变这个计数器的值来来取下一条语句指令,一个程序计数器只用来记录一个线程的行号,是线程私有的。
如果程序执行的是一个Java方法,则计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是一个本地(native,由C语言编写完成)方法,则计数器的值为Undefined,由于程序计数器只是记录当前指令地址,所以不存在内存溢出的情况,因此,程序计数器也是所有JVM内存区域中唯一一个没有定义OutOfMemoryError的区域。

二.HotSpot虚拟机对象

1.对象的创建
在JAVA中我们都使用过new来创建一个对象,那么在JVM中它做了哪些工作呢?
step1
在虚拟机遇到new时,它会在常量池中检查这个指令的符号引用,看它代表的类是否被加载解析初始化过,若没有则加载相应的类
step2
在加载完类以后需要给对象在堆中分配固定大小的内存,针对堆内存的是否完整性,可以有两种分配策略。

  • 指针碰撞策略:在内存绝对规整的情况下,所有用过的和没有用过的内存会切实分开,中间用一个指针最为分界器,指向空闲内存的首地址,当空闲内存被使用了以后,指针就会指向新的空闲内存的首地址。
    在这里插入图片描述

  • 空闲列表策略:在内存不规整的情况下,已用内存和空闲内存交错分布,用一个指针解决不了问题,此时就需要用一个列表来记录内存中的空闲块,当需要分配内存的时候就从空闲列表中选择一个,并做上已被使用的标记。

至于java内存的完整性与否,就看垃圾回收器是否有整理的能力啦。
在考虑了内存分配后,我们还要考虑在修改指针位置的时候是在多线程并发执行时是否会有呢问题呢?可能在A请求内存的时候,指针正在修改位置,此时B又来请求分配内存,针对这种情况我们也有两种机制:

1.首先能想到的当然是线程同步了
2.第二种方法就是把内存依据线程在不同空间进行,就是提前为每个线程在堆内存中分配一块内存,称为本地分配缓冲(TLAB)。后面我们还会具体讲解内存分配的。
step3
内存分配完成后,JVM还需要初始化分配的内存空间,都初始化为0,这样当我们创建对象但没有给对象赋初值的时候,也可以直接使用,因为默认为0了。
step4
在初始化以后,需要对对象进行必要的设置,如对象在那个类中,如何找到类的元数据信息,对象的分代年龄,对象的哈希码,这些都会存在对象的对象头中。
2.对象的内存布局
对象在内存中的布局有三部分:对象头,实例数据,对齐填充。

  • 对象头:HotSpot虚拟机的对象头分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。另一部分是类型指针,是对象指向它的类元数据的指针,虚拟机通过这个指针知道该对象是哪个类的实例。如果对象是java数组,那对象头中还必须有一块用于记录数组长度的数据。
  • 实例数据:对象存储的有效信息,即代码中定义的各种类型的字段内容
  • 对齐填充:该部分并不是必然存在。这部分是考虑到对象的大小必须是8字节的整数倍,因对象头部分确定必须是32位或者64位,即对齐填充主要用于填充不是8字节整数倍的实例数据。

3.对象的定向访问
对象实例是存储在堆中的,而程序操作对象时通过栈中的引用数据来操作对象。

Object o = new Object();

上面的一行代码,我们定义了一个Object对象o,反映为java虚拟机栈中局部变量表中的引用类型,而new Object会反映到java堆中,对应堆内存中一块存储对象实例的内存数据区。那引用数据是怎么定位到堆中的具体对象数据呢?主要有两种访问方式:
1.句柄访问:java堆中会单独用一块内存叫做句柄池,栈的引用类型数据存储的就是对象的句柄池地址,句柄池中存储了对象的实例数据(在堆中)和对象类型数据(在方法区中)的地址信息.这种表示方法由于用句柄表示地址,所以十分稳定。
在这里插入图片描述2.直接指针访问,即栈中的引用类型数据存储的是对象在堆中的实际地址,在堆中存储的对象信息包括了在方法区的对象类型数据的指针。这种方法最大的优点就是速度快。HotSpot虚拟机中就是用这种方法。
在这里插入图片描述

三.JVM内存分配

前面我们简单提到过内存分配,由于java对象的内存主要在堆中分配,因为堆是线程共享的,所以多线程时要加锁,但是这样创建一个对象的开销就很大,当堆空间不足的时候就会GC,GC后仍然不足就会抛出OutOfMemory异常。为了提升JVM分配内存的效率,使用下面的方法:
在年轻代的Eden区JVM采用了两种技术来加快内存分配。分别是bump-the-pointerTLAB(Thread-Local Allocation Buffers)。由于Eden分配是连续的,所以bump-the-pointer主要追踪Eden中存储的最后一个对象,看对象后面是否有足够的内存来存储即可,所以速度很快。对于TLAB主要对多线程来说,前面已经说过了,就是为每个新创建的线程在年轻代的Eden区划分独立的空间,这块空间叫做本地分配缓存(TLAB),在TLAB中分配内存不需要加锁,一般JVM会优先在TLAB中分配内存,当TLAB满了的时候,则继续在堆上进行分配。
在这里插入图片描述
总结内存分配的重要几点

  1. 对象优先在Eden区中分配,当Eden区中没有足够内存进行分配时,就进行一次Minor GC.
  2. 大对象直接进入老年代
  3. 长期存活的对象即将进入老年代
    虚拟机给每一个对象都定义了一个年龄计数器,在Eden出生的对象,经过一次Minor GC依然存活,并且能被Survior接纳的话,进入Survivor中就年龄增大一岁为1岁,对象在Survior每接收一次Minor GC年龄就增大一岁,当增大到15岁是=时,默认进入老年代。
    4.动态对象年龄判断
    虚拟机针对不同的程序内存状况,可以让对象的年龄在合适的时候就进入老年代,
    如果在Survivor空间中相同年龄的所有对象大小大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

四.内存的回收方式

JVM是由GC来回收堆和方法区中的内存的。这个过程是自动执行的。GC机制主要完成三件事:确定哪些内存要回收,什么时候回收,怎么回收。JVM主要通过收集器的方法来实现GC.主要的收集器有引用计数收集器和跟踪收集器。
4.1引用计数收集器
通过计数器采用分散式管理方式,通过计数器记录对象是否被引用,当计数器为0时证明此对象不再被引用了,因此可被回收,过程如下图:
在这里插入图片描述
由上图可以看到,在对象A释放了对对象B的引用的时候,对象B的计数器就为0,就可以回收对象B占用的内存。
引用计数器在每次对象赋值时都要进行引用计数器的值增减,有一定消耗。特别是对循环引用时不使用,假如B和C相互引用,那么在A释放对B的引用的时候,也不能回收B,所以堆java这种会使用复杂的引用关系的语言而言,引用计数器不合适,SunJDK也不使用这种方式实现GC.

4.2跟踪回收器
跟踪回收器采用集中式的管理,会全局记录所有数据的引用关系,在一定触发条件,如定时,空间不足,会需要从根开始扫面对象的引用关系,会造成程序短暂暂停。主要有**复制(Copying)、标记-清除(Mark-Sweep)和标记-压缩(Mark-Compact)**三种实现算法。

复制Copying

复制采用方式的是从根集合扫描存活的对象,并将找到的存活的对象复制到一块新的完全为被使用的空间。如图所示
在这里插入图片描述
当需要复制的对象很少时,使用这种算法很高效(年轻代的Eden就是使用这个算法),其带来的成本就是开辟一个新的空间,并进行对象的移动。

标记-清除 Marking-Deleting

标记-清除采取从根集合进行扫面,对存活的对象进行标记,标记完后,再进行一次扫描,将未标记的对象进行清除。标记过程如下:
在这里插入图片描述上面标记蓝色的是存活的对象,标记褐色的是未存活的对象,从根集合中标记过程进行扫描时比较耗时的。
清除过程如下:
在这里插入图片描述清除阶段回收没有被引用的对象,内存分配器会持有一个空闲空间列表,在有请求内存的需求时,通过查找空闲空间表进行内存分配。
标记-清除算法对存活的对象不进行移动,仅是对不存活的对象进行内存回收,对于存活对象较多时很高效,但容易造成内存碎片。

标记-压缩 Mark-Compact

标记-压缩和标记-清除一样是对未存活对象进行清除,但是在清楚后不一样的是,标记-压缩会对剩余的存活对象进行左移,将中间的空隙进行压缩,然后再更新引用其对象的指针。
在这里插入图片描述很明显,这解决了内存碎片的问题,使得空间利用率提高,但由于需要对对象进行移动,所以成本也很高。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值