JVM之内存管理与分配机制

这次来学习一下Java虚拟机(JVM)的内存管理机制,参考书籍为《深入理解Java虚拟机》。上一节说到Java代码执行时的链接阶段是虚拟机执行Java命令解析执行.class文件,这个过程中就会涉及到内存的管理与分配,虽然JVM有自动内存管理机制,不再需要为每一个new操作写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题。然而一旦出现内存泄漏和溢出方面的问题,如果不清楚JVM内存的内存管理机制,那么将很难排查与解决问题。

1. JVM运行时数据区划分

链接是在运行时动态执行的,.class文件不能直接运行,运行的是Java虚拟机,虚拟机引擎执行Java命令解析.class文件,转换为机器能识别的二进制代码,然后运行,所谓链接就是根据引用到的类加载相应的字节码并执行。在这个过程中JVM会用一段空间来存储执行程序期间需要用到的数据和相关信息,这段空间就是运行时数据区(Runtime Data Area),也就是常说的JVM内存。

JVM在执行Java程序的过程中会把它所管理的内存划分为如下图所示的若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
在这里插入图片描述
由上图看到,运行时数据区被划分为了线程隔离数据区和线程共享数据区两大类,前者为线程私有的内存区域。

1.1 程序计数器

每条线程都需要有一个程序计数器PC,如果所属线程正在执行的是一个Java方法,计数器记录的是正在执行的指令地址;如果所属线程正在执行的是Natvie 方法,这个计数器值为空(Undefined)。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,因此它是线程私有的内存。

1.2 虚拟机栈

Java 方法执行的内存模型,每个方法执行的时候,都会创建一个栈帧用于保存局部变量表,操作数栈,动态链接,方法出口信息等,是线程私有的内存,与线程生命周期相同,一个方法调用的过程就是一个栈帧从VM栈入栈到出栈的过程,大致理解可参考上篇文章。
在Java虚拟机规范中,对这个区域规定了两种异常状况:

  • StackOverflowError异常:线程请求的栈深度大于虚拟机所允许的深度,如死循环递归
  • OutOfMemoryError异常:虚拟机栈可动态扩展且扩展时无法申请到足够的内存
1.3 本地方法栈

和虚拟机栈发挥的作用相似,虚拟机栈执行 Java 方法(字节码)服务,Native 方法栈执行的是 Native 方法服务。也可能会抛出StackOverflowError和OutOfMemoryError异常。

1.4 堆

此内存区域唯一的目的就是存放对象实例和数组,几乎所有的对象和数组都在这分配内存,大致理解可参考上篇文章。堆是被所有线程所共享的一块内存区域,在虚拟机启动时创建。
堆是垃圾收集器管理的主要区域,也被称做“GC堆”,它可处于物理上不连续的内存空间中,只要逻辑上是连续的即可。如果在堆中没有内存完成实例分配,且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

1.5 方法区

方法区是各个内存所共享的内存空间,方法区中主要存放被 JVM 加载的类信息、常量、静态变量、即时编译后的代码等数据。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。一般来说GC的区域为堆和方法区。

运行时常量池

运行时常量池是方法区的一部分,会受到方法区内存的限制。之前学习Hook知识的时候学过Class文件的格式接触到过常量池的知识,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。运行时常量池相比Class文件常量池的一个重要特征是具备动态性,体现在并非只有预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。由于运行时常量池会受到方法区内存的限制,因此当常量池无法再申请到内存时也会抛出OutOfMemoryError异常。

2. JVM GC&内存分配

Java与C++之间有一堵由内存动态分配和垃圾回收机制所围成的高墙,墙外面的人想进去,墙里面的人出不来。

2.1 对象存活判定

2.1.1 引用计数法

每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收,且任何时刻计数器为0的对象是不可能再被使用的。此方法简单,无法解决对象相互循环引用的问题,因此主流的Java虚拟机里未选用此种算法来管理内存。

延伸-引用

引用有四种类型:

  • 强引用(StrongReference):最传统的“引用定义”,是指在程序代码之中普遍存在的引用赋值,例如Object obj = new Object()。具有强引用的对象不会被GC,即便内存空间不足,JVM宁愿抛出OutOfMemoryError使程序异常终止,也不会随意回收具有强引用的对象。
  • 软引用(SoftReference):描述一些还有用,但非必须的对象。只具有软引用的对象,会在内存空间不足的时候被GC,如果回收之后内存仍不足,才会抛出OOM异常;
  • 弱引用(WeakReference):描述那些非必须对象,强度比软引用弱一些。被弱引用关联的对象只能生存到下一次垃圾收集(GC)发生为止。当垃圾收集器开始工作,无论当前内存是否足够都会被回收掉。
  • 虚引用(PhantomReference):最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
2.1.2 可达性分析法

通过一系列被称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。如下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。

此外,在可达性分析算法中被判定不可达的对象,也不是“非死不可”的,至少要经历两次标记过程:1)判断对象是否有必要执行finalize()方法;2)若被判定为有必要执行finalize()方法,之后还会对对象再进行一次筛选,如果对象能在finalize()中重新与引用链上的任何一个对象建立关联,将被移除出“即将回收”的集合。此外重载finalize()方法可能会引起内存泄漏,因此应尽量不重载finalize方法。
在这里插入图片描述

在Java语言中,GC Roots包括:
   1)虚拟机栈中引用的对象。
   2)方法区中类静态属性实体引用的对象。
   3)方法区中常量引用的对象。
   4)本地方法栈中 JNI 引用的对象。
延伸-回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

  • 废弃常量与回收Java堆中的对象的GC很类似,即在任何地方都未被引用的常量会被GC。
  • 判断一个类型是否属于不再被使用的类需要同时满足下列三个条件:
    • 1)该类所有的实例都已被回收,即Java堆中不存在该类的任何实例;
    • 2)加载该类的ClassLoader已经被回收;
    • 3)该类对应的java.lang.Class对象没在任何地方被引用,即无法在任何地方通过反射访问该类的方法。

2.2 垃圾收集算法

2.2.1 分代收集算法

GC 分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。“分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

  • 新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
  • 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

2.2.2 复制算法

  • 把可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用尽后,把还存活着的对象“复制”到另外一块上面,再将这一块内存空间一次清理掉。
  • 优点:每次都是对整个半区进行内存回收,无需考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
  • 缺点:每次可使用的内存缩小为原来的一半,内存使用率低。
    在这里插入图片描述

2.2.3 标记-清除算法

  • 首先标记出所有需要回收的对象,然后统一清除所有被标记的对象。
  • 是最基础的收集算法,后续收集算法大多以它为基础。
  • 缺点:标记和清除过程的效率不高;空间碎片太多,标记、清除之后会产生大量不连续的内存碎片,可能会导致后续需要分配较大对象时,因无法找到足够的连续内存而提前触发另一次GC,影响系统性能。
    在这里插入图片描述

2.2.4 标记-整理算法

  • 首先标记出所有需要回收的对象,然后进行整理,使得存活的对象都向一端移动,最后直接清理掉端边界以外的内存。
  • 优点:即没有浪费50%的空间,又不存在空间碎片问题,性价比较高。
  • 一般情况下,老年代会选择标记-整理算法。
    在这里插入图片描述
延伸-安卓中GC触发时机
  • 程序主动请求GC,调用System.gc()方法触发:应避免出现在程序中调用,因为JVM有足够的能力来控制垃圾回收。
  • 当堆内存已满,系统需要更多内存的时候触发。
  • 当堆内存增长到一定阈值时会触发。此时触发可以对堆中的没有用的对象及时进行回收,腾出空间供新的对象申请,避免进行不必要的增大堆内存的操作。
  • 当试图分配内存,但失败的时候。

3. 内存模型与回收策略

对象的内存分配广义上是指在Java 堆(Java Heap)上分配,Java 堆是JVM所管理的内存中最大的一块,堆又是垃圾收集器管理的主要区域,Java 堆主要分为2个区域-年轻代与老年代,其中年轻代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 2个区。

  • Eden 区:大多数情况下,对象会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,虚拟机会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。 通过 Minor GC 之后,Eden 会被清空,Eden区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区(若 From 区不够,则直接进入 Old区)。
 新生代GC(Minor GC):发生在新生代的垃圾收集动作。较频繁、回收速度也较快。
 老年代GC(Major GC):发生在老年代的垃圾收集动作。出现Major GC经常会伴随至少一次的Minor GC。速度一般比Minor GC慢10倍以上。
 
  • Survivor 区 :Survivor 区相当于是 Eden 区和 Old 区的一个缓冲,类似于我们交通灯中的黄灯。Survivor 又分为2个区,一个是 From 区,一个是 To 区。每次执行 Minor GC,会将 Eden 区和 From 存活的对象放到 Survivor 的 To 区(如果 To 区不够,则直接进入 Old区)。Survivor 的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生。Survivor 的预筛选保证,只有经历16次 Minor GC还能在新生代中存活的对象,才会被送到老年代。

  • Old 区:老年代占据着2/3的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC都会触发“Stop-The-World”。内存越大, STW 的时间也越长,所以内存也不仅仅是越大就越好。由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低所以老年代这里采用的是标记-整理算法。

Stop-The-World:
在垃圾回收过程中经常涉及到对对象的挪动,进而导致需要对对象引用进行更新,为了保证引用更新的正确性,Java将暂停所有其他的线程,这种情况被称为“Stop-The-World”,导致系统全局停顿。
Stop-The-World对系统性能存在影响,因此垃圾回收的一个原则是尽量减少“Stop-The-World”的时间。

4.延伸-Jvm、Dalvik和Art的简单区别

DVM与JVM的区别

  • JVM基于栈,基于栈的机器必须使用指令来载入和操作栈上数据,Dalvik虚拟机基于寄存器。
  • JVM运行的是java字节码,Dalvik运行的是自己专属的.dex字节码格式(通过一个dx工具将所有的.class文件转换成一个.dex文件,然后从其中读取指令和数据)。
  • DVM由Zygote创建并初始化。

ART与DVM的区别

  • DVM中的应用每次运行时,字节码都需要通过即时编译器(JIT,just in time)转换为机器码,这会使得应用的运行效率降低。而在ART中,系统在安装应用时会进行一次预编译(AOT,ahead of time),将字节码预先编译成机器码并存储在本地,这样应用每次运行时就不需要执行编译了,运行效率也大大提升。
  • ART占用空间比Dalvik大(字节码变为机器码之后,可能会增加10%-20%),这就是“时间换空间大法”。
  • 预编译也可以明显改善电池续航,因为应用程序每次运行时不用重复编译,从而减少了 CPU 的使用频率,降低了能耗。

参考文章:

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值