深入理解JVM虚拟机-周志明【第三版】

          概述

  1. 走进虚拟机

  2. 自动内存管理
  3. 垃圾收集器与内存回收策略

  4. 虚拟机性能监控、故障处理工具

概述

Java 技术系: Kotlin 、Clojure 、JRuby、Groovy 均是运行在 Java 虚拟机上的程序语言

我们通常把Java 程序设计语言、Java虚拟机、Java 类库 三部分统称为 JDK

Java 前生叫做Oak , 1995 年改名Java,并发布第一个正式环境 JDK1.0

2004  jdk5 发布,将版本的命名分格改变,语法有较大的改变

2014 jdk8 发布 支持 Lambda 表达式、移除 HotSpot 的永久代

一、走进虚拟机

最原始的虚拟机 sun  Classic /Exact VM

Classic 虚拟机特点:无法执行即使编译,通过外挂的方式可以即时编译就会完全托管编译

Exact Vm : 精准的内存管理

HotSpot VM : 继承前两款虚拟机的优点,用于独特的热点代码探测技术

Mobile/Embedded Vm :用于嵌入式

BEA  JRockit: 号称速度最快,后被收购

IBM J9 VM

软硬合璧 BEA Liquid VM / Azul VM :与特定的硬件平台绑定

等等

二、自动内存管理

 参考:https://www.jianshu.com/p/76959115d486

1.程序计数器

Java 多线程之间切换,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各个线程之间的计数器互不影响

2.虚拟机栈 / 栈

栈的生命周期与线程相同,为线程私有,Java 虚拟机执行方法就会同步创建栈帧 Stack Frane ,用于存储局部变量表、操作数栈、动态连接、方法出口

局部变量表存储了编译期可知的虚拟机基本数据类型、对象引用(可能是对象地址、也可能是代表对象的句柄)、返回地址,这些局部变量在局部变量表中以局部变量槽Slot 来表示。

64 位的 long 与 double 会占用 2 个局部变量槽

3.本地方法栈

虚拟机栈是为虚拟机执行Java 方法服务,而本地方法栈是为虚拟机执行本地方法服务

4.堆

按照 HotSpot 虚拟机来说,会分为 新生代、老年代、永久代、Eden 空间、From Survivor 、To Survivor 等空间,其原因是因为GC 回收都基本在堆上进行的,按垃圾收集行为来区分的。而现在垃

圾收集器与过去有较大的不同,有点虚拟机gc 都不是采用分代收集  (举个栗子)多线程下为了更好的分配对象,线程共享的堆可以划分出多个线程私有的分配缓冲区 (Thread Local Allocation

Buffer),以提升对象分配时的效率

5.方法区 Method Area

属于线程共享,用于存储被虚拟机加载的类型信息、常类、静态变量、即使编译器编译后的代码缓存数据(别名:非堆、永久代),在这个区也存在垃圾回收,主要针对常量池的回收与类型的卸载

6.运行时常量池 Runtime Constant Pool

运行时常量池是方法区的一部分,Class 文件中出了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,

这部分内容在类加载后存放到方法区的运行时常量池中

7.直接内存 Direct Memory

直接内存不是虚拟机运行时数据区的一部分,但也被频繁的使用,而且也可能导致 OutOfMemoryError 异常。在 JDK 1.4 加入 NIO( New Input/OutPut)类,引入一种基于通道 Channel 与 缓冲区

Buffer 的IO 方式,它可以使用 Native 函数库直接直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。在一些场景中提高了性能,比避免

了在Java 堆与 Native堆中来回复制数据。虽然该内存不受Java 堆内存限制,但是收到机器内存的限制,也可能导致内存溢出

对象的创建

  当Java 虚拟机遇到一条字节码 new 指令时,首先检查这个指令的参数是否能够在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载、解析、初始化过。

如果没有,则必须执行相应的类加载过程。

  在类加载检查通过后,接下来虚拟机将为新对象分配内存,对象所需内存大小在类加载完成后便可完全确定,为对象分配空间的任务实际上等同于把一块确定大小的内存从Java 堆中划分出出来。

假设Java 堆是绝对规整的,所有使用过的对象放在一边,未使用的对象放在另一边,中间方着一个指针作为分界线的指示器,分配对象就是把指针往空闲方向挪动一块与对象大小相等的距离,这种

分配方式称为指针碰撞 (Bump the Pointer).但如果Java堆的内存并不是规整的,已近使用的内存与未使用的内存交错放在一起,虚拟机必须维护一个列表,记录那些内存是空闲的,在分配的时候

从列表中找到一块足够大的空间分配给实例,并更新列表上的记录,这种分配方式称为空间列表(Free List)。选择哪种分配方式是由Java 堆是否规整决定的,Java 堆是否规整则由垃圾收集器是否

带有空间压缩整理(Compact)的能力决定的。而使用 Serial 、ParNew 等带有压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,简单高效。而使用 CMS 这种基于清除算法的收集器时,

理论上只能采用较为复杂的空闲列表来分配内存。

  还需要考虑另一个问题是对象在虚拟机中创建是非常频繁的行为,即使是修改一个指针指向的位置,在并发的情况下可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同事使用了原

来的指针分配内存。解决这个问题有两种方案: 一 堆分配内存空间进行同步处理--实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性;另外一种是把内存分配的动作划分在不同的空间

中进行,即为每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer TLAB),只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用

TLAB 可以通过 -XX: +/-UseTLAB 设定

  内存分配完,虚拟机必须将分配的内存空间(不包括对象头)都初始化为零值,如果使用TLAB ,则这一项提前到 TLAB 分配时进行。

  Java 虚拟机还要对对象头进行必要的设计,如对象是哪个类的实例、如何才能找到元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用 Object::hashCode() 方法时才计算),对象

的GC 分代年龄等信息,是否启动偏向锁等。这些信息存在对象头中,Object Header.

  上面的工作完成后,从虚拟机的角度,一个新的对象已经产生了。单从Java 的角度,对象的创建才刚开始--构造函数。 Class 文件 <init>方法还没执行,所有的字段为默认零值,对象需要按照意图

构造好。

 对象的内存布局

  在HotSpot 虚拟机里,对象在堆内存中存储的布局可以划分为三部分:对象头(Header)、实例数据(Instance Data)、和对齐填充(Padding)

  HotSpot 虚拟机对象的对象头部分存储两类信息,第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄锁状态标志、线程持有锁、偏向锁ID、偏向时间戳等。这部分数据

在长度32位和64位的虚拟机(未开启压缩指针)中分别是 32 个比特 、64个比特。官方统称位 Mark Word 。对象需要存储的运行时数据很多,其实已经超过32、64位所能记录的最大长度,但对象头里的

信息是与对象自身定义的数据无关的额外成本,考虑到虚拟机的空间效率,MarkWord 的32位比特存储空间中: 25 个用于存储对象哈希码、4个用于存储对象分代年龄、2个用与存储锁标志位、1个比特固

定为 0 ,如下图

        

   对象头的另一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不一定所有的虚拟机实现都必须在对象上保留类型指针,换句话说是

查找对象元数据信息并不一定要经过对象本身。如果对象是Java 数组,那么对象头中还必须有一块用于记录数组长度的数据。

  接下来实例数据部分才是对象真正存储的有效信息,即在代码里定义的各种类型的字段内容、无论从父类继承下来还在字类定义的字段都必须记录下来,这部分的存储顺序会收到虚拟机分配策略参数

和字段在Java 代码定义顺序影响。HotSpot 虚拟机默认的分配顺序: long/double 、int、shorts/chars、byte/boolean

  对象的第三部分是对齐填充,这并不是必然存在的,它仅仅起着占位符的作用。由于 HotSpot 虚拟机的自动内存管理系统要去对象的起始地址必须是8字节的整数倍,对象头的部分已经是整数倍,如果

没有实例数据,将不需要对齐填充

对象的访问定位

  访问对象通过栈上的reference 数据来操作具体的对象,访问的方式由虚拟机实现而定的,主流的访问方式主要有使用句柄直接指针

  如果使用句柄访问,Java堆将可能会划分出一块内存作为句柄池,reference 中存储的地址就是句柄地址,而句柄中包含了对象实例数据和类型数据具体的地址

       

   如果使用直接指针访问,Java 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference 中存储的地址是对象地址,如果是访问对象本身的话,就不需要多一次间接的访问

     

 使用直接指针的方式的好处是速度快,节省了一次指针定位的时间开销,由于访问对象多,中间会消耗可观的时间成本

三、垃圾收集器与内存分配策略

3.1 对象是否已经死亡

引用计数:在对象中添加一个引用计数器,每当有一个地方引用就将计数器加一,引用失效就减一,当计数器为零表示对象不再被使用。该算法原理简单 ,效率高,但是无法解决循环引用的,

其中微软的 COM 技术Python语言就是使用了引用技术作为内存关联

可达性分析Java 、C# 、Lisp 使用的都是可达性分析算法,通过一系列的GC roots 的根对象作为起点,根据引用向下搜索,搜索走过的路径称为引用链,如果某个对象与 GC roots没人任何引用

链,则认为是不可达的也就是不能被使用的。可以作为GC root 的节点:

  1. 栈帧中本地变量表引用的对象
  2. 方法区静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈引用的对象
  5. 虚拟机内部引用的对象,如常用的异常对象
  6. 同步锁持有的对象

3.2 再谈引用

jdk1.2 之后,Java 对引用进行扩充,分为强引用、软引用、弱引用、虚引用

强引用 例如new Object ,任何情况下,只要强引用关系存在,就不会有垃圾回收及收集

软引用 软引用是早内存溢出之前,将软引用的对象列入二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常

弱引用 弱引用的对象只能生存到下一次垃圾收集器发生为止

虚引用 也叫作幽灵引用。是最弱的引用,为一个对象设置虚引用唯一的目的是该对象回收会收到一个系统通知

  在可达性分析算法中判断对象不可达,也并非”非死不可“,需要经过2次的标记: 如果在可达性分析后发现对象没有引用链,它将会被第一次标记,随后进行一次筛选,筛选的条件是对象是否有必要执行

finalize 方法。没必要执行:对象没有覆盖 finalize() 方法,finalize() 方法已经被虚拟机调用过。

  如果对象有必要执行finalize() 方法,将会被放置在一个 F-Queue的队列中,由一条低优先级的线程执行他们的 finalize 方法。如果对象在被回收期重新被引用,那么第二次回收将被移除队列。如果这时对象

还没有逃脱,那么将被回收。

3.3 回收方法区

  方法区的回收主要是两部分内容:废弃的常量不能再使用的类型

如果常量池中某常量没有任何引用,那么发送回收就会被清理。判断一个类是否被回收,需要满足一下条件

  1. 所有该类的实例都被回收了
  2. 加载该类的类加载器被回收
  3. 该类对应的Java.lang.Class 对象没有任何地方被引用,无法通过反射获取该类的方法

3.4 垃圾收集算法

分代收集理论

原文地址:https://www.cnblogs.com/baizhuang/p/13821856.html