月泉的博客

初识JVM

月泉 JVM

初识JVM

大纲如下:

  1. JVM运行时数据区
  2. 对象是如何在虚拟中创建的
  3. 对象的内存布局
  4. 对象访问的定位
  5. 垃圾回收算法
  6. 垃圾收集算法
  7. Hotspot的实现
  8. JVM的垃圾收集器
  9. 对象的引用类型

JVM运行时数据区

JVM运行时数据区主要分为5块,先从粗略的角度看,分为线程共享和线程独享

线程共享: 堆、方法区(Java8后被移除,用元空间替代其作用还是一样的)

线程独享: Java方法栈、本地方法栈、程序计数器

线程共享的区域随着虚拟机的启动而启动,虚拟机的销毁而销毁,线程独享的区域随着线程的启动而启动线程的消亡而消亡

:基本上所有的对象都是在堆上完成分配,也是GC关注的重点对象,因为现在大多垃圾回收器都是使用的分代回收算法,从粗略的角度看堆分为新生代和老年代,再细看一点新生代中又分为:Enden区和Survivor区,其中Survivor是2块内存一样大的空间,堆的大小可以通过 -Xmx和-Xms来进行调整,-Xms是用来设置初始堆大小,-Xmx是用来设置堆的最大容量,当堆的空间已经满了没有足够的空间来分配对象并且也无法再扩展时,将会抛出OutOfMemoryError异常。

方法区/元空间:方法区主要是用来存储加载的类信息、静态变量、常量和JIT生成的代码等数据,方法区的容量可以使用-XX:MaxPermSize来调整上限,但是在Java8中已经移除了,用元空间来代替,元空间默认使用本机内存,当方法区没有足够的容量来存储加载的类信息、静态变量、常量和JIT生成的代码等数据时,将会抛出。OutOfMemoryError,方法区中还有一块区域叫做运行时常量池,当类被加载后除了一些字段、方法定义、接口等自描述信息外,其中还会有一个叫做常量池的东西,这些信息就是在类加载后存放至运行时常量池中的。

Java方法栈/本地方法栈:因为这2个东西的含义是一样的,只不过一个是针对程序中写的Java方法和虚拟机的方法而已,所以放在一起写,这里主要介绍Java方法栈,线程启动时都会有一个栈,每一个方法的调用都会产生一个栈帧,一个栈帧的入栈和出栈意味着一个方法的调用与结束,栈帧主要是用来存放,方法的局部变量表,操作数栈、方法出口、动态链接等信息,局部变量表主要是用来存放一些基础变量的信息(double/long float/int char/short byte/boolean)和引用类型(reference,它不等同于对象本身,有可能是一个指向对象起始地址的指针,也有可能是一个引用对象的句柄)和returnAddress类型,其中double和long 64位的类型会占用2个位置来存储,其余的都占用一个,局部变量表所需要的空间在编译时期就可以计算得出,当进入一个方法时,该局部变量表需要多大的空间是可以完全确定的,在方法运行期间是不会更改局部变量表的空间的,当然当栈请求的深度大于栈所规定的深度会抛出StackOverflowError异常,当无法空间不足且无法扩展时抛出OutOfMemoryError

程序计数器:其主要就是记录当前要执行的指令是什么,通过修改程序计数器的指令来引导解释器下一步要执行的是什么指令,首先Java是多线程的,其线程之间的执行机制是使用轮转执行,当线程进行切换时也能够根据程序计数器中记录的指令回到线程切换前/后的指令

对象是如何在虚拟机中创建的

当创建一个对象时(clone、deserialized)或new等等,通常我们都是用一个new来创建对象,当接收到new这个指令时,虚拟机通常会将new 后面的参数(类)吧,首先会判断这个类是否被虚拟机:加载、解析、初始化过,如果没有则执行相应的加载过程,然后根据类信息计算所需要的内存,对象所需要的内存可以在类加载完后就计算出来,如何为对象分配内存有很多种方式,这里列举3种,一种是指针碰撞一种是空闲列表一种是本地线程分配缓冲的概念,首先指针碰撞,在堆内存规整的情况下,想象一条线,最左边是0最右边是内存最大值,有一个指针,指针从左往右移动,从左往右移动多少就是使用了多少内存,还有一种堆内存不规整的情况,这种情况由于空间不是连续的,无法使用简单的碰撞,所以虚拟需要维护一个列表,来记录每一块内存,那些内存是可用的,当分配一个对象时就从这些内存块中选一块给对象分配,这种分配的方式又称为空闲列表,具体是那种分配方式要看堆内存是否是规整的来决定,堆内存是否规整又要根据堆使用的垃圾收集算法是否带有整理压缩功能来决定,有了分配方式后,又要考虑一个问题,对象的创建是频繁的,此时就涉及到一个分配时的并发问题,当对象还在创建的过程中指针还没有进行挪动,下一个对象也进入了分配流程这时候是不是就会产生竞争,这种情况就是典型的数据竞争,How to 解决?当然是要么使用同步或者规避(让他们不产生数据竞争),使用同步:锁、CAS(Hotspot使用)使用规避,就是在堆上为每个线程都划分一块本地线程缓冲区(LTAB),他们创建对象都在自己的本地线程缓冲区中完成,是否使用LTAB可以使用虚拟机参数 -XX:+/-UseLTAB,当对象的内存分配完成后,虚拟机需要为分配的对象内存空间初始化零值,也正是有这一步操作,使得了我们在创建对象的时候没有进行初始化对象也可以进行访问,当然程序仅只能访问到访问类型的零值,然后虚拟开始对该对象设置对象头,然后在调用<init>方法进行初始化(简单理解你为对象设置的初始化行为,例如构造函数等)。

对象内存布局

一个对象在内存中存储的布局分为三个结构:对象头、实例数据、填充对齐

对象头:对象头分为2个部分对象自身运行时数据和类型指针

对象自身运行时数据: 哈希码、GC分代年龄、线程持有的锁、锁状态标志、偏向线程ID、偏向时间戳等,这一部分的数据长度,根据x86/64环境的不同而不同,官方简称这一部分为”Marker word“,在32位的环境下一个未使用锁的对象这一部分的长度是32位宽,其中25位是Hash码、GC年龄占4位、偏向锁标志1位、锁标志2位,在使用了锁的情况下,线程ID占用23位、Epoch占用2位、GC年龄占用4位、偏向锁标志1位、锁标志2位,在64位环境下是64位宽在没有使用锁的情况下未使用占用25位、hash码占用31位、还有一个未使用也占1位、GC分代年龄4位,偏向锁标志1位,锁标志2位,在使用了锁的情况下:线程ID占用54位、epoch占用2位、未使用占用1位、GC年龄占用4位、偏向锁使用1位、锁标志2位。

类型指针 :就是指向对象的类元数据的指针,用来感知到这个对象是什么类的实例

实例数据:实例数据故名思意,其中就是存放定义的各种字段内容(包括父类),这部分的存储顺序一部分受虚拟机分配策略参数的影响和字段在源码中定义的顺序,Hotspot默认使用的分配顺序是,宽位相同的排列在一起,例如:double/long float/int char/short byte/boolean,当然通常父类定义的变量会出现再子类前

填充对齐:将道理这个英文叫做”padding“,大家都叫它对齐填充,填充对齐,我也不知道正确的中译名是什么,说个梗,我想叫它内边距(233),其实这一部分并不是必要的,没有太多的特别含义,只是用来做占位符的,因为虚拟机规定对象只能是8的整数倍字节大小,因此当对象实例没有满足8的整数倍大小时就需要用对齐填充来补全。

对象访问定位

常用的2种方式

  • 句柄
  • 直接指针

句柄

通过访问句柄的方式来获得对象实例再堆中的地址和在元空间的类信息,简而言之就是通过句柄来封装在堆中存放的对象地址和在元空间存放的类信息地址,栈在使用时只需要通过句柄来访问即可

直接指针

直接访问堆对象的地址,在通过对象头中的类型指针来访问元空间存储的类信息地址

使用句柄的好处就是稳定,因为栈在使用的时候只需要访问句柄就可以了,当对象地址放生变化的时候,也仅仅是变更句柄中的对象地址,引用它的本身并不需要修改,使用直接指针的方式的好处很简单粗暴,因为它快啊,节约了一次指针定位的成本

垃圾回收算法

最常见的2种

  • 程序计数器
  • 可达性分析

程序计数器

最耳熟能详的一个算法,其本质就是维护一个对象引用的计数器,当对象被引用计数器+1,当对象引用消失则-1,为0时代表没有地方引用可回收,但程序计数器算法虽然简单,但有一个比较大的问题解决起来也比较麻烦,就是循环引用的问题,例如有实例A、B,实例中的 A.instan = B,实例中的B.instance = A,然后并没有其它用途的对象。

可达性分析

通过一系列GC Roots的对象,然后对对象的引用进行可达性分析,跟随节点不断的探索下去,这个探索的路径可以称为引用链,当一个对象不可达时,则证明这个对象是不可用的是可回收的。

垃圾收集算法

最常见的几种

  • 标记-清除
  • 复制算法
  • 标记整理算法
  • 分代算法(复合算法)

标记清除

分为2个阶段分别是标记和清除,先将无用的对象进行标记,如何判别无用的对象,请看上面的垃圾回收算法,然后再进行清除,这种算法有2个问题,第一个是效率并不高,第二个是清除后的空间并不是连续的,这就可能会造成,当这次GC结束后,要分配一个大对象的空间,但空间没有这个大对象的连续空间,从而触发GC

复制算法

将一块内存分成二块等同大小的内存,每次只使用其中一块,另一块用做交换,例如再GC时先将再使用的这一块内存空间存活的对象放到交换空间上去,然后清除掉,然后这一块又备用成交换空间,这种算法有一个问题,就是牺牲了一半的空间来做这件事情。

标记整理算法

和标记清除很类似,只不过增加了整理功能,就是再清除的时候一边清除一边挪动对象到规整的一边,从而让内存保持规整,这种算法也有一个问题就是耗时比较长效率不高

分代算法

简而言之就是将对象存储的区域分成好几块然后将他们称为代,新生的对象全部放再这一块,当GC到一定的次数还存活的对象就放入另一代来减少GC的次数,每一代可能采用不同的GC算法,就比如JVM中Hotspot的堆内存的实现就是使用了分代算法(具体根据垃圾收集器来啊,我这里是狭义的说明一种典型的垃圾收集器的实现啊),将堆内存分为新生代和老年代,然后又将新生代分为了Eden区和Survior区,其中Eden和Survior区又使用的复制算法,老年代又使用标记整理。

Hotspot的实现

首先是可达性分析,这个要是在产生GC的时候才去扫描所有的对象枚举各种根节点,进行可达性分析的话,那GC的时间可太长了,在Hotspot中使用了OopMap的数据结构来干这件事情,当类加载完后,Hotspot就把对象内什么偏移量是什么类型的数据给计算出来,这样在GC扫描的时候就可以直接得知这些信息了根据OopMap提供的信息直接进行可达性分析。

安全点

有了OopMap可以快速准确的完成GC Roots的可达性分析,要知道对象的依赖变换是很频繁的,能够导致OopMap中产生变化的指令非常多,那么又不可能为每一条指令都区创建OopMap吧?所以有了安全点的概念,意思就是只有在代码执行到了所谓的安全点,才会产生OopMap,那么又要如何才能执行到安全点呢?安全点的定义就是程序是否拥有长时间执行的特征为标准来选定,例如:方法调用、循环跳转、异常跳转等等,只有具有这些指令的才会产生安全点,那么又来一个问题,那么在GC的时候如何保证所有的线程都达到安全点?有2种常见的策略分别是:抢占式和主动式

抢占式:中断所有线程,如果有没有跑到安全点的线程就让它跑到安全点再中断

主动式:不直接操作线程,给所有线程打上中断标志,线程会在合适的时机去轮询这个标志,发现有这个标志,那么就会主动挂起

安全区域

有了安全点那为什么还要有安全区域?要知道安全点是代码执行到安全点吧?那如果有不执行的代码咧?例如:sleep或者blocked状态的线程,这时候的线程是无法响应中断请求的,对于这种情况就使用安全域来解决,安全域就是指一段代码种不会发生引用对象的关系变化,从这个区域的任何地方开始GC都是安全的。

垃圾收集器

这里说Hotspot的垃圾收集器实现

新生代

  • Serial
  • ParNew
  • Parallel Scavenge

老年代

  • Serial Old
  • Parallel Old

  • CMS

全功能

  • G1

Serial

在JDK1.3以前这是新生代的唯一选择,它是单线程的,通过复制算法来对新生代进行垃圾回收,但是它在垃圾回收的时候会中断所有线程

ParNew

Serial的升级版,从单线程变成了多线程,同样会产生中断

Parallel Scavenge

这是一种比较注重吞吐量的垃圾回收器,它对吞吐量判别的标准是

吞吐量 = 代码运行时间 / (代码运行时间 + 垃圾收集时间)

就比如虚拟机运行100分钟,代码运行了99分钟,而垃圾收集时间只花了1分钟,那么吞吐量就是百分之99,这个垃圾回收器有2个参数可以精准控制吞吐量,分别是最大垃圾收集停顿时间-XX:MaxGCPauseMills和设置吞吐量大小的-XX:GCTimeRatio,MaxGCPauseMills的参数必须是一个大于0的整数,不过不要以为把这个参数设小了,就会让你高枕无忧的加快GC回收速度,缩短时间是有代价的,它会缩减新生代的内存来尽可能达到你设置的这个时间内完成回收,空间换时间,空间小了,回收自然快了,CGTimeRatio的参数应该是一个大于0小于100的一个整数,它是用来控制垃圾收集时间占比的,还有一个参数-XX:+UseAdapterSizePolicy参数,这是一个开关参数,打开以后虚拟机会收集性能监控数据,来动态调整吞吐量。

Serial Old

它是Serial在老年代实现的垃圾收集器,同理也是单线程版本,但其使用标记整理算法,主要有2大用途,一种是配合Parallel Scavenge使用,还有一种就是做为CMS的备选方案

Parallel Old

它是Parallel Scavenge在老年代的实现,是多线程的,同时其使用标记整理算法

CMS

全称:Concurrent Mark Sweep,该收集器是一种以最短停顿时间为目标的收集器,它也是基于标记清除算法来实现,只不过它清理分为了几个步骤分别为:初始标记、并发标记、重新标记、并发清除,在初始标记时会产生一个小中断,只是标记一下GC Roots所关联的对象,然后通过并发标记并发的去做可达性分析,然后重新标记也会产生中断,其目的是为了修正并发标记期间因用户程序继续运行导致标记产生变动的标记记录,这一阶段可能耗时会比较长一些,然后再并发清除,但它也有几个缺点,首先它是对CPU资源敏感的,因为它会占用一部分线程,CMS默认启动的线程是(CPU数量 + 3)/4 也就是在4个以上时,并发收集线程不会低于25%,但是如果当CPU不足4个,比如是2个的那么这时候并发收集线程就是50%了,如果本来CPU负载就很大的情况下,突然分出50%的计算资源出去,这时候对应用程序的运行影响是比较大的,同时它无法处理并发清除时产生的浮动垃圾,还有一个缺点就是它使用的标记清除算法,那么可能导致老年代的内存空间是不规整的,如果碎片很多,突然来一个大对象的创建,这时候是很伤的,就会再次触发Full GC来回收下空间,为了搞定这个问题虚拟机也是提供了一个参数-XX+UseCMSCompactAtFullCollection开关参数,默认是打开的,其目的是在内存碎片过多时进行合并整理,但会延长GC时间

G1

这是一个全功能型的垃圾收集器,能够管理整个堆,同时它是并发并行的,它任然保留了分代的概念,但G1各代存储的地址并不是一直连续的,每一代都使用了相同大小的Region每一块Region都有一个连续的虚拟内存空间地址同时它还有一个可预测的停顿,它的垃圾回收也分为了几个生命周期:初始标记、根区域扫描、并发标记、重新标记、独占清理、并发清理阶段,说一下根区域扫描,由于初始标记阶段会伴随一次新生代GC,那么Eden区就会被清空,活下来的对象就是到Survior空间,但在根区域扫描阶段将扫描Survior区可直达老年代的区域并标记这些直接可达的对象,这个过程可以和程序并发执行,但不能和新生代GC并发执行,因为会产生数据竞争的问题,如果发送这个问题GC需要等待根区域扫描完成,独占清理这个阶段也会引起停顿,因为它会计算各个区域存活的对象和GC回收比例,根据最有价值的回收策略来回收

对象的引用类型

强引用

强引用就是我们通常创建的对象都是强引用,在它成为垃圾之前是不会被回收掉的。

软引用

软引用就是表示这个引用有用但不重要可以被回收,当堆空间不足时,会将软应用给清理掉。

弱引用

弱引用,通常会在下一次GC时被清理掉

虚引用

一个持有虚引用的对象和没有引用几乎一样,通过虚引用的get也拿不到所引用的对象,它主要是用来跟踪对象回收的过程。

月泉
伪文艺中二青年,热爱技术,热爱生活。