【进阶之路】攻克JVM——JVM对象及对象的访问定位(一)

1,293 阅读20分钟

导言

大家好,我是南橘,从接触java到现在也有差不多两年时间了,两年时间,从一名连java有几种数据结构都不懂超级小白,到现在懂了一点点的进阶小白,学到了不少的东西。知识越分享越值钱,我这段时间总结(包括从别的大佬那边学习,引用)了一些平常学习和面试中的重点(自我认为),希望给大家带来一些帮助

这是之前的几篇文章,有兴趣的同学可以看看(暗搓搓给自己打广告)

这一片文章的思路来自于猿人谷大佬,大佬技术非常好,写的文章也很硬,吃起来非常满足。(^_^)

有需要的同学可以加我的公众号,以后的最新的文章第一时间都在里面,也可以找我要思维导图

这篇文章讲的是JVM,当然JVM博大精深,几篇文章是讲不完的,这里我就只能抛砖引玉,希望大家看了我的文章之后能有多一些的了解。

一、运行时数据区

开门见山的说,这张图是JVM中基础的基础,所以,本着由易到难的原则,我先带大家一起复习一下java的运行时数据区

如图所示,堆和方法区是所有线程共享的公共区域,堆和方法区所占的内存空间是由JVM负责管理的,在该区域内的内存分配是由HotSpot的内存管理模块维护的,而内存的释放工作则由垃圾收集器自动完成。虚拟机栈本地方法栈程序计数器是线程的私有区域,每个线程都关联着唯一的栈和程序计数器,并仅能使用属于自己的那份栈空间和程序计算器来执行程序。

1、堆

堆是java虚拟机中的一块线程共享区域,也是整个java虚拟机中最大的一块,它的主要目的是存放对象实例,而除了栈上分配等优化技术之外,几乎所有的对象实例都由这里分配内存空间堆的大小不是固定的,可以随着需求去动态扩展,也可以根据需求自动收缩。Java堆的物理内存可以不用那么连续,只需要逻辑上是连续的就行。例如G1垃圾收集器重新定义了堆空间,打破了原有的分代模型,将堆划分为一个个区域,每个分区也不会确定地为某个代服务,可以按需在年轻代(Eden)老年代(Old)之间切换。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

同时,堆也是垃圾收集器的重点关照对象,一般说的垃圾回收,大多数指的是GC堆”(Garbage Collected Heap)。当创建对象实例的时候,一般会在Eden区划出一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的。否则,将有可能出现两个对象共用一段内存的事故。解决方法就是,Java堆中可能划出多个线程私有的分配缓冲区TLAB

2、方法区

方法区所有线程共享的内存区域、和堆同时创建、但与堆分开。

方法区储存被虚拟机加载的类信息(包括方法和构造函数的字节码、类、实例、接口初始化用到的特殊方法)、常量池静态常量即时编译器编译后的代码方法数据方法表等数据。

方法区大小不是固定的,jvm可以根据应用的需要动态调整。jvm也可以允许用户和程序指定方法区的初始大小,最小和最大限制。同时,方法区同样存在垃圾收集,因为通过用户定义的类加载器可以动态扩展Java程序,这样可能会导致一些类被废弃。不过,对方法区内存回收主要是针对常量池的回收和对类型的卸载

3、虚拟机栈

每一条Java虚拟机线程都有自己私有的Java虚拟机栈,它的生命周期与线程相同。每个方法在执行的同时都会创建一个栈帧(stack frame)用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

4、本地方法栈

在我看来本地方法栈和虚拟机栈其实很相似,只不过本地方法栈是用来处理虚拟机使用到的native方法。 Java虚拟机规范允许本地方法栈实现成固定大小或者根据计算来动态扩展和收缩。如果采用固定大小的本地方法栈,那么每一个线程的本地方法栈容量可以在创建栈的时候独立选定。

5、程序计数器

JAVA每一条虚拟机线程都有自己独立的程序计数器、每一个JAVA虚拟机线程只会执行一个方法的代码,正在被线程执行的方法如果不是native的、那程序计数器保存的是正在执行的字节码指令地址、如果是native方法,那程序计数器的值为空

程序计数器是一块小内存空间,它可以看作是当前线程所执行的字节码的行号指示器。此内存区域是唯一一个没有任何OutOfMemoryError情况的区域。

6、栈帧

说到了栈,顺便就讲一下栈帧吧。

栈帧存储了方法的局部变量表操作数栈动态连接方法返回地址等信息。每一个方法调用,就是一个压栈的过程,每个方法的结束就是一个弹栈的过程。压栈都将会将该栈帧置于栈顶,每个栈不会同时操作多个栈帧,只会操作栈顶,当栈顶操作结束时,会将该栈帧弹出,同时会释放该栈帧内存,其下一个栈帧将变为栈顶。栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

猿人谷大佬的图

二、创建对象

了解了JVM的基础运行时数据区后,我们要开始探索Java是如何创建对象的。

已知JAVA中对象可以采用new、反射、clone和反序列化的方法创建。

虚拟机在收到new指定的时候:

  • 1、从栈的顶部获得目标对象在常量池中的索引
  • 2、定位到目标对象的类型
  • 3、虚拟机将根据该类的状态,采取相应的内存分配技术,在内存中分配实例空间

在这个时候,虚拟机会去验证类是否已被解析过,如果若该类已被加载和正确解析,使用快速分配(fast allocation)技术为该类分配对象空间,如果没有,那么则只能通过慢速分配(slow allocation)方式分配实例对象

  • 4、完成实例数据和对象头的初始化

三、快速分配和慢速分配

既然在创建对象的时候提到了快速分配和慢速分配,那我们就来聊聊什么叫快速分配,什么叫慢速分配。

1、快速分配

概念: HotSpot通过线程局部分配缓存技术(Thread-Local Allocation Buffers,即TLABs)可以将分配之前已经完成解析的对象在线程私有区域实现空间的分配。

这句话怎么理解?

根据上文,我们知道对象的实例以及数组都在堆上实现,而堆是一个全局共享的区域,当多个线程同一时刻操作堆内存分配对象空间时,就需要进行同步,而同步带来的效果就是对象分配效率变差(尽管JVM采用了CAS的形式处理分配失败的情况),但是对于存在竞争激烈的分配场合仍然会导致效率变差。

那怎么办?当然是以公有制为主体,多种所有制。。。串了,智慧的研发人员在JVM添加了 -XX:UseTLAB这样一个参数,只要设置了的话,在线程初始化时候,也会在内存中申请一块私有的空间给线程单独使用,以此避免同步带来的效率问题,从而提高分配效率。(虽然这个空间很小,但是也可以根据 -XX:TLABSize这个参数去调节大小,只是不推荐太大。

另外值得说明的是,虽然TLAB在分配对象空间的时候是无锁分配,但是TLAB空间本身在分配的时候还是需要锁的,G1中使用了CAS来并行分配

同时,根据是否使用TLAB,快速分配方式有两种选择策略:

  • 选择TLAB:首先尝试在TLAB中分配,因为TLAB是线程私有区域,故不需要加锁便能够确保线程安全。在分配一个新的对象空间时,将 (一)首先尝试在TLAB空间中分配对象空间,若分配空间的请求失败,就 (二)尝试分配一个新的TLAB分配对象,还不行,才会 (三)试图使用加锁机制在Eden区分配对象
  • 选择Eden空间:若失败,则尝试在共享的Eden区进行分配,Eden区是所有线程共享区域,需要保证线程安全,故采用原子操作进行分配。若分配失败,则再次尝试该操作,直到分配成功为止。

从TLAB已分配的缓冲区空间直接分配对象,也称为指针碰撞法分配,其方法非常简单,在TLAB中保存一个top指针用于标记当前对象分配的位置,如果剩余空间(end-top)大于待分配对象的空间(objSize),则直接修改top = top + ObjSize,相关代码位于thread->tlab().allocate(size)中

当对象实例生成之后,就可以对实例进行初始化。初始化完毕后,所属的虚拟机栈就会对对象进行引用。那么,如果该类之前没有被解析呢?

2、慢速分配

正是因为在分配实例前需要对类进行解析,确保类及依赖类已得到正确的解析和初始化,才需要进行慢速分配。

慢速分配的流程如下

  • 1、首先尝试对堆分区进行加锁分配
  • 2、不成功,则判定是否可以对新生代分区进行扩展,如果可以扩展则扩展后再分配
  • 3、不成功,判定是否可以进行垃圾回收,如果可以进行增量垃圾回收后再分配
  • 4、不成功,则进行GC垃圾回收,注意这里的回收主要是Full GC,然后再分配
  • 5、最终成功分配或者失败达到一定次数,则分配失败

所以,对于慢速分配来说,不成功,便成仁。

四、逃逸分析和栈上分配

1、逃逸分析

逃逸分析并不是直接的优化手段,而是一个代码分析,通过动态分析对象的作用域,为其它优化手段如栈上分配、标量替换和同步消除等提供依据,发生逃逸行为的情况有两种:方法逃逸和线程逃逸。

如果不存在逃逸行为,则可以对该对象进行如下优化:同步消除、标量替换和栈上分配。

同步消除 线程同步本身比较耗内存,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁。

通过-XX:+EliminateLocks可以开启同步消除。

标量替换 如果逃逸分析发现一个对象不会被外部访问,并且该对象可以被拆散,那么经过优化之后,并不直接生成该对象,而是在栈上创建若干个成员变量;

通过-XX:+EliminateAllocations可以开启标量替换, -XX:+PrintEliminateAllocations查看标量替换情况。

2、栈上分配

前文有提过,几乎所有的对象都在堆中分配。

我们可以设想这样一种情况,有的对象的作用域并不会出现在方法外,生命周期会随着方法的调用开始而开始,方法的调用结束而结束。如果还是在堆中生成对象实例的话,当方法结束的时候,该对象也没用了,需要被GC回收了。而如果存在大量的这种情况,对GC来说无疑是一种负担。

因此,JVM提出栈上分配的概念,针对作用域在方法内的对象,如果满足了逃逸分析,就会将对象属性打散后分配在栈上(线程私有的,属于栈内存),这样,随着方法的调用结束,栈空间的回收就会随着将栈上分配的打散后的对象回收掉,不再给GC增加额外的无用负担,从而提升应用程序整体的性能

五、内存分配的两种方法

创建一个类的对象时,虚拟机需要为新生对象分配内存空间,而对象的大小在类加载完成后已经确定了,所以分配内存只需要在Java堆中(或者栈上)划分出一块大小相等的内存。在Java虚拟机中有指针碰撞和空闲列表两种方式分配内存。

  • 1、指针碰撞

适合场景:内存堆规整的情况(没有内存碎片)

如果Java堆中内存是规整排列的,所有被用过的内存放一边,空闲的可用内存放一边,中间放置一个指针作为它们的分界点,在需要为新生对象分配内存的时候,只要将指针向空闲内存那边挪动一段与对象大小相等的距离即可分配

  • 2、 空闲列表

适用场景:堆内存不规整的情况

如果Java堆中内存不是规整排列的,用过的内存和可用内存是相互交错的,这种情况下将不能使用指针碰撞方式分配内存,Java虚拟机需要维护一个列表用于记录哪些内存是可用的,在为新生对象分配内存的时候,在列表中寻找一块足够大的内存分配,并更新列表上的记录。

六、对象的访问定位

对象建立成功之后,Java程序需要通过栈上的reference数据来操作堆上的具体对象,目前主流的访问方式有使用句柄和直接指针两种:

1、直接指针

如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。即使用直接指针访问在对象被移动时reference本身需要被修改,reference存储的就是对象地址。如下图所示:

大佬的图片

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

我们非常熟悉的HotSpot就是使用直接指针进行对象访问的

2、句柄

如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,如下图所示:

也是大佬的图

使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象时非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

七、强、软、弱、幻四级引用

在面试的时候,偶尔会有面试官提问这四种引用的区别,所以我们也来了解一下这四种引用的关系。

话不多说,先看看我借来的图:

1、强引用

新初始化的对象就是强引用,在强引用面前,即使JVM内存空间不足、JVM宁可抛出OOM,也不会回收强引用对象。对于一个普通对象而言,如果没有其他引用关系,超过了引用的作用域或者被显式的赋值为null,那么就会被正常的回收。

但要注意的是,并不是赋值为null后就立马被垃圾回收,具体的回收时机还是要看垃圾收集策略的。

2、软引用

软引用相对强引用要弱化一些,可以让对象豁免一些垃圾收集。当内存空间足够的时候,垃圾回收器不会回收它。软引用可以用来实现很多内存敏感点的缓存场景、通常可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,java虚拟机就会把这个软引用加入到与之关联的引用队列中。

在使用软引用的时候必须检查引用是否为null。因为垃圾收集器可能在任意时刻回收软引用,如果不做是否null的判断,可能会出现NullPointerException的异常。

3、弱引用

弱引用指向的对象是一种十分临近finalize状态的情况,当弱引用被清除的时候,就符合finalize的条件了。弱引用与软引用最大的区别就是弱引用比软引用的生命周期更短暂。垃圾回收器在扫描它所管辖的内存区域的过程中,只要发现弱引用的对象,不管内存空间是否有空闲,都会立刻回收它

4、幻引用

幻象引用,也有被说成是虚引用或幽灵引用。幻象引用并不会决定对象的生命周期。即如果一个对象仅持有虚引用,就相当于没有任何引用一样,在任何时候都可能被垃圾回收器回收。不能通过它访问对象,幻象引用仅仅是提供了一种确保对象被finalize以后,做某些事情的机制(如做所谓的Post-Mortem清理机制),也有人利用幻象引用监控对象的创建和销毁

5、对象的回收

垃圾回收准备放在下篇文章讲,这里就讲一下普通的对象回收

一个对象死亡,至少要经历两次标记过程

  • 1、如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后被一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。
  • 2、如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后被一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统奔溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可。譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

对象的可达性是JVM垃圾收集器决定如何处理对象的一个重要考虑指标,除了幻象引用(因为get永远返回null),如果对象还没有被销毁,都可以通过get方法获取原有对象。利用软引用和弱引用,我们可以将访问到的对象,重新指向强引用,也就是人为的改变了对象的可达性状态。所以对于软引用、弱引用之类,垃圾收集器可能会存在二次确认的问题,以确保处于弱引用状态的对象没有改变为强引用。

结语

JVM第一章写完啦,一边写一遍又复习了一遍,感觉JVM不懂的东西又增加了,特别是TLAB上的内容,好不容易找到源码了,发现自己又看不太懂,然后一边找资料一遍慢慢看咯。代码太长了,于是乎就不贴上来给大家看了,有兴趣的可以去找一下,还是能了解到不少东西的。see you!

同时需要思维导图的话,可以联系我,毕竟知识越分享越香!