文章首发于公众号:BaronTalknode
书籍真的是常读常新,古人说「书读百遍其义自见」仍是颇有道理的。周志明老师的这本《深刻理解 Java 虚拟机》我细读了不下三遍,每一次阅读都有新的收获,每一次阅读对 Java 虚拟机的理解就更进一步。于是萌生了将读书笔记整理成文的想法,一是想检验下本身的学习成果,对学习内容进行一次系统性的复盘;二是给还没接触过这部好做品的同窗推荐下,在阅读这部佳做以前能经过个人文章一窥书中的精华。git
原想着一篇文章就够了,但写着写着就发现篇幅大大超出了预期。看来仍是功力不够,索性拆成了六篇文章,分别从自动内存管理机制、类文件结构、类加载机制、虚拟机执行引擎、程序编译与代码优化、高效并发六个方面来作更加细致的介绍。本文先说说 Java 虚拟机的自动内存管理机制。程序员
Java 虚拟机在执行 Java 程序的过程当中会把它所管理的内存区域划分为若干个不一样的数据区域。这些区域都有各自的用途,以及建立和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束而创建和销毁。Java 虚拟机所管理的内存被划分为以下几个区域:github
程序计数器是一块较小的内存区域,能够看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工做时就是经过改变这个计数器的值来选取下一条须要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都须要依赖这个计数器来完成。「属于线程私有的内存区域」算法
就是咱们平时所说的栈,每一个方法被执行时,都会建立一个栈帧(Stack Frame)用于存储局部变量表、操做栈、动态连接、方法出口等信息。每一个方法从被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从出栈到入栈的过程。「属于线程私有的内存区域」数组
局部变量表:局部变量表是 Java 虚拟机栈的一部分,存放了编译器可知的基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,不等同于对象自己,根据不一样的虚拟机实现,它多是一个指向对象起始地址的引用指针,也多是指向一个表明对象的句柄或者其余与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。安全
和虚拟机栈相似,只不过虚拟机栈为虚拟机执行的 Java 方法服务,本地方法栈为虚拟机使用的 Native 方法服务。「属于线程私有的内存区域」数据结构
对大多数应用而言,Java 堆是虚拟机所管理的内存中最大的一块,是被全部线程共享的一块内存区域,在虚拟机启动时建立。此内存区域的惟一做用就是存放对象实例,几乎全部的对象实例都是在这里分配的(不绝对,在虚拟机的优化策略下,也会存在栈上分配、标量替换的状况,后面的章节会详细介绍)。Java 堆是 GC 回收的主要区域,所以不少时候也被称为 GC 堆。从内存回收的角度看,Java 堆还能够被细分为新生代和老年代;再细一点新生代还能够被划分为 Eden Space、From Survivor Space、To Survivor Space。从内存回收的角度看,线程共享的 Java 堆可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。「属于线程共享的内存区域」并发
用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。「属于线程共享的内存区域」函数
运行时常量池: 运行时常量池是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(Constant Pool Table),用于存放在编译期生成的各类字面量和符号引用。
直接内存:直接内存(Direct Memory)并非虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。Java 中的 NIO 可使用 Native 函数直接分配堆外内存,而后经过一个存储在 Java 堆中的 DiectByteBuffer 对象做为这块内存的引用进行操做。这样能在一些场景显著提升性能,由于避免了在 Java 堆和 Native 堆中来回复制数据。直接内存不受 Java 堆大小的限制。
前面介绍了 Java 虚拟机的运行时数据区,了解了虚拟机内存的状况。接下来咱们看看对象是如何建立的、对象的内存布局是怎样的以及对象在内存中是如何定位的。
要建立一个对象首先得在 Java 堆中(不绝对,后面介绍虚拟机优化策略的时候会作详细介绍)为这个要建立的对象分配内存,分配内存的过程要保证并发安全,最后再对内存进行相应的初始化,这一系列的过程完成后,一个真正的对象就被建立了。
先说说内存分配,当虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否可以在常量池中定位到一个类的符号引用,而且检查这个符号引用表明的类是否已被加载、解析和初始化。若是没有,那必须先执行相应的类加载过程。在类加载检查经过后,虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后即可彻底肯定,为对象分配内存空间的任务等同于把一块肯定大小的内存从 Java 堆中划分出来。
在 Java 堆中划份内存涉及到两个概念:指针碰撞(Bump the Pointer)、空闲列表(Free List)。
若是 Java 堆中的内存绝对规整,全部用过的内存都放在一边,空闲的内存放在另外一边,中间放着一个指针做为分界点的指示器,那所分配的内存就牢牢是把指针往空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为「指针碰撞」。
若是 Java 堆中的内存并非规整的,已使用的内存和空闲的内存相互交错,那就没办法简单的进行指针碰撞了。虚拟机必须维护一个列表来记录哪些内存是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为「空闲列表」。
选择哪一种分配方式是由 Java 堆是否规整来决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
保证并发安全
对象的建立在虚拟机中是一个很是频繁的行为,哪怕只是修改一个指针所指向的位置,在并发状况下也是不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的状况。解决这个问题有两种方案:
对分配内存空间的动做进行同步处理(采用 CAS + 失败重试来保障更新操做的原子性);
把内存分配的动做按照线程划分在不一样的空间之中进行,即每一个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪一个线程要分配内存,就在哪一个线程的 TLAB 上分配。只有 TLAB 用完并分配新的 TLAB 时,才须要同步锁。
内存分配完后,虚拟机要将分配到的内存空间初始化为零值(不包括对象头),若是使用了 TLAB,这一步会提早到 TLAB 分配时进行。这一步保证了对象的实例字段在 Java 代码中能够不赋初始值就直接使用。
接下来设置对象头(Object Header)信息,包括对象是哪一个类的实例、如何找到类的元数据、对象的 Hash、对象的 GC 分代年龄等。
这一系列动做完成以后,紧接着会执行方法,把对象按照程序员的意图进行初始化,这样一个真正意义上的对象就产生了。
JVM 中对象的建立过程大体以下图:
在 HotSpot 虚拟机中,对象在内存中的布局能够分为 3 块:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头包含两部分信息,第一部分用于存储对象自身的运行时数据,好比哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。这部分数据称之为 Mark Word。对象头的另外一部分是类型指针,即对象指向它的类元数据指针,虚拟机经过它来肯定对象是哪一个类的实例;若是是数组,对象头中还必须有一块用于记录数组长度的数据。(并非全部全部虚拟机的实现都必须在对象数据上保留类型指针,在下一小节介绍「对象的访问定位」的时候再作详细说明)。
对象真正存储的有效数据,也是在程序代码中所定义的各类字段内容。
无特殊含义,不是必须存在的,仅做为占位符。
Java 程序须要经过栈上的 reference 信息来操做堆上的具体对象。根据不一样的虚拟机实现,主流的访问对象的方式主要有句柄访问和直接指针两种。
Java 堆中划分出一块内存来做为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
使用句柄访问的好处就是 reference 中存储的是稳定的句柄地址,在对象被移动时只须要改变句柄中实例数据的指针,而 reference 自己不须要修改。
在对象头中存储类型数据相关信息,reference 中存储的对象地址。
使用直接指针访问的好处是速度更快,它节省了一次指针定位的开销。因为对象访问在 Java 中很是频繁,所以这类开销聚沙成塔也是一项很是可观的执行成本。HotSpot 中采用的就是这种方式。
在前面咱们介绍 JVM 运行时数据区的时候说过,程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而死;栈中的栈帧随着方法的进入和退出而有条不紊的执行着入栈和出栈的操做。每个栈帧中分配多少内存基本上在数据结构肯定下来的时候就已经知道了,所以这几个区域内存的分配和回收是具备肯定性的,因此不用过分考虑内存回收的问题,由于在方法结束或者线程结束时,内存就跟着回收了。
而 Java 堆和方法区则不同,一个接口中的多个实现类须要的内存可能不同,一个方法中的多个分支须要的内存也可能不同,咱们只有在程序运行期才能知道会建立哪些对象,这部份内存的分配和回收是动态的,垃圾收集器要关注的就是这部份内存。
垃圾收集器在作垃圾回收的时候,首先须要断定的就是哪些内存是须要被回收的,哪些对象是「存活」的,是不能够被回收的;哪些对象已经「死掉」了,须要被回收。
判断对象存活与否的一种方式是「引用计数」,即对象被引用一次,计数器就加 1,若是计数器为 0 则判断这个对象能够被回收。可是引用计数法有一个很致命的缺陷就是它没法解决循环依赖的问题,所以如今主流的虚拟机基本不会采用这种方式。
可达性分析算法又叫根搜索算法,该算法的基本思想就是经过一系列称为「GC Roots」的对象做为起始点,从这些起始点开始往下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 对象之间没有任何引用链的时候(不可达),证实该对象是不可用的,因而就会被断定为可回收对象。
在 Java 中可做为 GC Roots 的对象包含如下几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象;
方法区中类静态属性引用的对象;
方法区中常量引用的对象;
本地方法栈中 JNI(Native 方法)引用的对象。
不管是经过引用计数器仍是经过可达性分析来判断对象是否能够被回收都设计到「引用」的概念。在 Java 中,根据引用关系的强弱不同,将引用类型划为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。
强引用:Object obj = new Object()
这种方式就是强引用,只要这种强引用存在,垃圾收集器就永远不会回收被引用的对象。
软引用:用来描述一些有用但非必须的对象。在 OOM 以前垃圾收集器会把这些被软引用的对象列入回收范围进行二次回收。若是本次回收以后仍是内存不足才会触发 OOM。在 Java 中使用 SoftReference 类来实现软引用。
弱引用:同软引用同样也是用来描述非必须对象的,可是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生以前。当垃圾收集器工做时,不管当前内存是否足够,都会回收掉只被弱引用关联的对象。在 Java 中使用 WeakReference 类来实现。
虚引用:是最弱的一种引用关系,一个对象是否有虚引用的存在彻底不影响对象的生存时间,也没法经过虚引用来获取一个对象的实例。一个对象使用虚引用的惟一目的是为了在被垃圾收集器回收时收到一个系统通知。在 Java 中使用 PhantomReference 类来实现。
在可达性分析中断定为不可达的对象,也不必定就是「非死不可的」。这时它们处于「缓刑」阶段,真正要宣告一个对象死亡,至少须要经历两次标记过程:
第一次标记:若是对象在进行可达性分析后被断定为不可达对象,那么它将被第一次标记而且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize() 方法。对象没有覆盖 finalize() 方法或者该对象的 finalize() 方法曾经被虚拟机调用过,则断定为不必执行。
第二次标记:若是被断定为有必要执行 finalize() 方法,那么这个对象会被放置到一个 F-Queue 队列中,并在稍后由虚拟机自动建立的、低优先级的 Finalizer 线程去执行该对象的 finalize() 方法。可是虚拟机并不承诺会等待该方法结束,这样作是由于,若是一个对象的 finalize() 方法比较耗时或者发生了死循环,就可能致使 F-Queue 队列中的其余对象永远处于等待状态,甚至致使整个内存回收系统崩溃。finalize() 方法是对象逃脱死亡命运的最后一次机会,若是对象要在 finalize() 中挽救本身,只要从新与 GC Roots 引用链关联上就能够了。这样在第二次标记时它将被移除「即将回收」的集合,若是对象在这个时候尚未逃脱,那么它基本上就真的被回收了。
前面介绍过,方法区在 HotSpot 虚拟机中被划分为永久代。在 Java 虚拟机规范中没有要求方法区实现垃圾收集,并且方法区垃圾收集的性价比也很低。
方法区(永久代)的垃圾收集主要回收两部份内容:废弃常量和无用的类。
废弃常量的回收和 Java 堆中对象的回收很是相似,这里就不作过多的解释了。
类的回收条件就比较苛刻了。要断定一个类是否能够被回收,要知足如下三个条件:
该类的全部实例已经被回收;
加载该类的 ClassLoader 已经被回收;
该类的 Class 对象没有被引用,没法再任何地方经过反射访问该类的方法。
正如标记-清除的算法名同样,该算法分为「标记」和「清除」两个阶段:
首先标记出全部须要回收的对象,在标记完成后回收全部被标记的对象。标记-清除算法是一种最基础的算法,后续其它算法都是在它的基础上基于不足之处改进而来的。它的不足体如今两方面:一是效率问题,标记和清除的效率都不高;二是空间问题,标记清除以后会产生大量不连续的内存碎片,空间碎片太多可能会致使之后程序的运行过程当中又要分配较大对象是,没法找打足够的连续内存而不得不提早出发下一次 GC。
为了解决效率问题,因而就有了复制算法,它将内存一分为二划分为大小相等的两块内存区域。每次只使用其中的一块。当这一块用完时,就将还存活的对象复制到另外一块上面,而后再把已使用过的内存空间一次清理掉。这样作的好处是不用考虑内存碎片问题了,简单高效。只不过这种算法代价也很高,内存所以缩小了一半。
如今的商业虚拟机都采用这种算法来回收新生代,在 IBM 的研究中新生代中的对象 98% 都是「朝生夕死」,因此并不须要按照 1:1 的比例来划分空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。 HotSpot 默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用的内存为整个新生代容量的 90%(80%+10%),只有 10% 会被浪费。固然,98% 的对象可回收只是通常场景下的数据,咱们没办法保证每次回收后都只有很少于 10% 的对象存活,当 Survivor 空间不够用时,须要依赖其它内存(这里指老年代)进行分配担保。若是另一块 Survivor 空间没有足够空间存放上一次新生代收集下来存活的对象时,这些对象将直接经过分配担保机制进入老年代。
经过前面对复制-收集算法的介绍咱们知道,其对老年代这种对象存活时间长的内存区域就不适用了,而标记整理的算法就比较适用这一场景。
标记-整理算法的标记过程与「标记-清除」算法同样,可是后续步骤不是直接对可回收对象进行清理,而是让全部存活的对象都向一端移动,而后直接清理掉端边界之外的内存。
当前商业虚拟机的垃圾搜集都采用「分代回收」算法,这种算法并无什么新的思想,只是根据对象存活周期的不一样将内存划分为几块。通常是将 Java 堆分为新生代和老年代,这样能够根据各个年代的特色采用最合适的搜集算法。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少许存活,那就选用复制算法,只须要付出少许存活对象的复制成本就能够完成收集。
而老年代中由于对象存活率高、没有额外空间对它进行分配担保,就必须使用「标记-清除」或者「标记-整理」算法来进行回收。
所谓自动内存管理,最终要解决的也就是内存分配和内存回收两个问题。前面咱们介绍了内存回收,这里咱们再来聊聊内存分配。
对象的内存分配一般是在 Java 堆上分配(随着虚拟机优化技术的诞生,某些场景下也会在栈上分配,后面会详细介绍),对象主要分配在新生代的 Eden 区,若是启动了本地线程缓冲,将按照线程优先在 TLAB 上分配。少数状况下也会直接在老年代上分配。总的来讲分配规则不是百分百固定的,其细节取决于哪种垃圾收集器组合以及虚拟机相关参数有关,可是虚拟机对于内存的分配仍是会遵循如下几种「普世」规则:
多数状况,对象都在新生代 Eden 区分配。当 Eden 区分配没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC。若是本次 GC 后仍是没有足够的空间,则将启用分配担保机制在老年代中分配内存。
这里咱们提到 Minor GC,若是你仔细观察过 GC 平常,一般咱们还能从日志中发现 Major GC/Full GC。
Minor GC 是指发生在新生代的 GC,由于 Java 对象大多都是朝生夕死,全部 Minor GC 很是频繁,通常回收速度也很是快;
Major GC/Full GC 是指发生在老年代的 GC,出现了 Major GC 一般会伴随至少一次 Minor GC。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。
所谓大对象是指须要大量连续内存空间的对象,频繁出现大对象是致命的,会致使在内存还有很多空间的状况下提早触发 GC 以获取足够的连续空间来安置新对象。
前面咱们介绍过新生代使用的是标记-清除算法来处理垃圾回收的,若是大对象直接在新生代分配就会致使 Eden 区和两个 Survivor 区之间发生大量的内存复制。所以对于大对象都会直接在老年代进行分配。
虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须判断哪些对象应该放在新生代,哪些对象应该放在老年代。所以虚拟机给每一个对象定义了一个对象年龄的计数器,若是对象在 Eden 区出生,而且可以被 Survivor 容纳,将被移动到 Survivor 空间中,这时设置对象年龄为 1。对象在 Survivor 区中每「熬过」一次 Minor GC 年龄就加 1,当年龄达到必定程度(默认 15) 就会被晋升到老年代。
为了更好的适应不一样程序的内存状况,虚拟机并非永远要求对象的年龄必需达到某个固定的值(好比前面说的 15)才会被晋升到老年代,而是会去动态的判断对象年龄。若是在 Survivor 区中相同年龄全部对象大小的总和大于 Survivor 空间的一半,年龄大于等于该年龄的对象就能够直接进入老年代。
在新生代触发 Minor GC 后,若是 Survivor 中任然有大量的对象存活就须要老年队来进行分配担保,让 Survivor 区中没法容纳的对象直接进入到老年代。
对于咱们 Java 程序员来讲,虚拟机的自动内存管理机制为咱们在编码过程当中带来了极大的便利,不用像 C/C++ 等语言的开发者同样当心翼翼的去管理每个对象的生命周期。但同时咱们也丧失了内存控制的管理权限,一旦发生内存泄漏若是不了解虚拟机的内存管理原理,就很排查问题。但愿这篇文章能对你们理解 Java 虚拟机的内存管理机制有所帮助。若是想对 Java 虚拟机有更进一步的了解,推荐你们去读周志明老师的《深刻理解 Java 虚拟机:JVM 高级特性与最佳实践》这本书。
好了,关于 Java 虚拟机的自动内存管理机制就介绍到这里,下一篇咱们来聊聊「类文件结构」。
参考资料:
《深刻理解 Java 虚拟机:JVM 高级特性与最佳实践(第 2 版)》
若是你喜欢个人文章,就关注下个人公众号 BaronTalk 、 知乎专栏 或者在 GitHub 上添个 Star 吧!