前言
了解JVM是对Java程序员的基本要求,但是有多少同学和我有一样醉心解bug堆布局,忘记了内功修炼,对JVM的理解是零碎的。系统地学习一次JVM也许能让我们在这条路走得更好更远。
了解Java虚拟机家族
我们可以把Java程序设计语言、Java虚拟机、Java类库这三部分统称为JDK(Java Development
Kit),JDK是用于支持Java程序开发的最小环境。
JVM 是 JDK 的一部分,《Java 虚拟机规范》(The Java Virtual Machine Specification) 是平行于《Java 语言规范》(The Java Language Specification)的一套独立的规范,不同的公司对其有不同的实现 (类似于一个接口被不同的类实现)。
虚拟机始祖:Sun Classic/Exact VM
- 世界上第一款商用Java虚拟机
- 在JDK 1.2之前是JDK中唯一的虚拟机
- JDK 1.4的时候,Classic VM才完全退出商用虚拟机的历史舞台被HotSpot取代
武林盟主:HotSpot VM
- 是Sun/OracleJDK和OpenJDK中的默认Java虚拟机,也是目前使用范围最广的Java虚拟机
- 如它名称中的HotSpot指的就是它的热点代码探测技术
- 为全世界使用最广泛的Java虚拟机
小家碧玉:Mobile/Embedded VM
- 面对移动和嵌入式市场,Java ME中的Java虚拟机
天下第二:BEA JRockit/IBM J9 VM
- JRockit虚拟机曾经号称是“世界上速度最快的Java虚拟机”,是一款一款专门为服务器硬件和服务端应用场景高度优化的虚拟机。JRockit随着BEA被Oracle收购,现已不再继续发展
- IBM J9虚拟机的市场定位与HotSpot比较接近,它是一款在设计上全面考虑服务端、桌面应用,再到嵌入式的多用途虚拟机
软硬合璧:BEA Liquid VM/Azul VM
- 与特定硬件平台绑定、软硬件配合工作的专有虚拟机
挑战者:Apache Harmony/Google Android Dalvik VM
- Apache Harmony是一个Apache软件基金会旗下以Apache License协议开源的实际兼容于JDK 5和JDK 6的Java程序运行平台,它含有自己的虚拟机和Java类库API,用户可以在上面运行Eclipse、Tomcat、Maven等常用的Java程序。
- Dalvik虚拟机并不是一个Java虚拟机,它没有遵循《Java虚拟机规范》,不能直接执行Java的Class文件,使用寄存器架构而不是Java虚拟机中常见的栈架构。但是它与Java却又有着千丝万缕的联系,它执行的DEX(Dalvik Executable)文件可以通过Class文件转化而来,使用Java语法编写应用程序,可以直接使用绝大部分的Java API等。
Java内存区域划分与OutOfMemory
根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:
程序计数器
- 是当前线程所执行的字节码的行号指示器
- 线程私有: 每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储
- 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)
- 没有OutOfMemory
Java虚拟机栈
- 线程私有
- 虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程
- 局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)
- 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常
本地方法栈
- 与虚拟机栈所发挥的作用相似,为虚拟机使用到的本地方法服务
Java堆
- 是虚拟机所管理的内存中最大的一块
- 所有的对象实例以及数组都应当在堆上分配
- Java堆是被所有线程共享的一块内存区域
- 所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)Java堆内存是线程共享的!面试官:你确定吗?
- Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
方法区
- 与Java堆一样,是各个线程共享的内存区域
- 它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
- 在JDK 8完全废弃了永久代的概念,改用元空间来代替。
- 运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
- 运行期间也可以将新的常量放入池:比如String类的intern()方法
- 如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。
直接内存
- 并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域
- 在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。
- 会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,而导致动态扩展时出现OutOfMemoryError异常
Hotspot虚拟机对象
对象的创建
- 当Java虚拟机遇到一条字节码new指令时,检查是否类已加载、解析、初始化,如果没有,则进行类加载
- 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。
- 线程安全解决方案:1、CAS+失败重试保证更新操作的原子性 2、TLAB把内存分配的动作按照线程划分在不同的空间进行
- 内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值
- 接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来
对象的内存布局
- 在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
- 对象头包括:对象自身的运行时数据(Mark Word),所属类类指针,数组长度(如果是数组对象)
对象的访问定位
- Java程序会通过栈上的reference数据来操作堆上的具体对象。
- 对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种
垃圾收集(GC)
对象的死亡
引用计数法
- 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
- 互相循环引用的对象无法被回收
可达性分析法
- 通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的
- 可作为GC Roots的对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 在方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器
- 所有被同步锁(synchronized关键字)持有的对象
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
四种引用
- 强引用(Strongly Re-ference):无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
- 软引用(Soft Reference):只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
- 弱引用(Weak Reference):当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
- 虚引用(Phantom Reference):为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知( 虚引用必须与ReferenceQueue一起使用,当GC准备回收一个对象,如果发现它还有虚引用,就会在回收之前,把这个虚引用加入到与之关联的ReferenceQueue中)
宣告对象死亡
- 要真正宣告一个对象死亡,至少要经历两次标记过程
- 首先对象不可达,它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
- 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。
- 如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可
- 如果第二次标记时对象没有逃脱,那基本上它就真的要被回收了
垃圾收集算法
标记-清除算法(Mark-Sweep)
- 首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
- 缺点:1、执行效率不稳定 2、 是内存空间的碎片化问题
标记-复制算法(Semispace Copying)
- 将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。解决内存碎片化问题。
- 缺点:空间浪费
标记-整理算法
- 标记过程仍然与“标记-清除”算法一样,然后让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
- 这种对象移动操作必须全程暂停用户应用程序才能进行,被最初的虚拟机设计者形象地描述为“Stop The World”
分代收集
在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域
——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法。
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
Hotspot虚拟机 堆内存划分
内存分配与回收策略
- 对象优先在Eden分配:大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC
- 大对象直接进入老年代:大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组
- 长期存活的对象将进入老年代:虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中
- 动态对象年龄判定:如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
- 空间分配担保:当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域 (实际上大多数情况下就是老年代) 进行分配担保。在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 - XX:HandlePromotionFailure 参数的设置值是否允许担保失败 (Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者-XX: HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次 Full GC。
参考资料:
《深入理解JAVA虚拟机》1-3章