本文主要是阅读<<深入理解Java虚拟机>>前几章节的一些摘记与总结,由于本人并不从事JVM相关工作,因此摘录整理的都是一些较为简单的概念,以期对内存模型与垃圾回收有一个宏观的认识。
Java内存模型
概述
Java虚拟机在运行的时候会将它所管理的内存划分成若干个不同的数据区域,如图:

程序计数器(The pc Register)
程序计数器可以看做当前线程所执行的字节码的行号指示器。在虚拟机的概念模型中,字节码解释器就是通过改变程序计数器中的值来选取下一条需要执行的字节码指令。
因为Java虚拟机有线程切换的功能,为了让线程切换回来后能从之前挂起的位置继续运行,因此每个线程都有自己独有的程序计数器(每个线程自己管理比统一管理更简单),这样每次切换回来,线程读取自己的计数器,即可继续正确执行指令。
如果线程执行的是Java方法,那么这个计数器记录的就是正在执行的字节码指令的地址;如果正在执行的Native方法,那么此时程序计数器的值为Undefined。
这里有个细节,参考这个问题 Java多线程执行native方法时程序计数器为空,那么线程切换后如何找到之前执行到哪里了?。更多的细节可以参考官方文档 Chapter 2. The Structure of the Java Virtual Machine 2.6.4. Normal Method Invocation Completion 这一节的内容。
简单回答上面的问题,就是在线程切换后,CPU寻找下一条需要执行的指令的方式有两个,如果之前执行的Java方法,那可以根据JVM提供的信息定位,如果执行的是Native代码,那么就根据Nativie层提供的信息定位。而代码执行完以后如何继续执行,则是另外一个问题,方法执行完以后都有返回值(一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中会保存这个计数器值),收到返回值后栈帧会有相应的pop操作,这样代码就得以继续进行。
Java虚拟机栈(Java Virtual Machine Stacks)、本地方法栈( Native Method Stacks)
Java虚拟机栈是线程私有的,它的声明周期与线程一致。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的时候都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到完成的过程,就对应着栈帧在虚拟机栈中入栈到出栈的过程。用一张图来描述的话:

本地方法栈的作用于虚拟机栈的作用相似,只不过它是为native方法执行服务的,具体不再赘述。
Java堆( Heap)
Java堆是线程共享的一块内存,用于存放对象实例。根据Java虚拟机规范,Java堆可以处于物理上不连续的内存空间上。由于现在的GC算法大多采用分代回收,因此Java堆还可以继续细分成更小的空间,以便回收算法的实现。
方法区(Method Area,别名Non-Heap)
方法区也被所有的线程所共享,用于存放被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
关于即时编译器(JIT)的一些细节,可以参考文章:深入浅出 JIT 编译器
运行时常量池(Run-Time Constant Pool)
运行时常量池对应着class文件中的 constant_pool table,用于存放编译期生成的各种字面量(literals,字面量相当于Java语言层面的常量)和符号引用(Symbolic References)。
同时它还可以用于存放一些在运行时生成的常量(注意这个常量指的是编程时所说的常量,如字符串常量等,而上面的常量则是包括字面量和符号引用的),比如String的intern()方法。
关于符号引用的细节,可以参考:JVM里的符号引用如何存储?
直接内存(Direct Memory)
不属于虚拟机运行时数据区的一部分,被称为堆外内存。
NIO的Buffer提供了一个可以不经过JVM内存直接访问系统物理内存的类——DirectBuffer。 DirectBuffer类继承自ByteBuffer,但和普通的ByteBuffer不同,普通的ByteBuffer仍在JVM堆上分配内存,其最大内存受到最大堆内存的限制;而DirectBuffer直接分配在直接内存中,并不占用堆空间,其可申请的最大内存受操作系统限制。
Java对象
对象的创建
1、Java的对象创建有以下四种方式:
- 使用new关键字
- 使用newInstance()方法
- 使用clone()方法
- 反序列化
上面的四种创建对象的方法除了第一种使用new指令之外,其他三种都是使用invokespecial(构造函数的直接调用)。
2、对象创建过程
类加载检查:在串讲类之前先检查类是否被加载过。
分配内存:类被加载后,其对应对象的大小就是确定的,因此JVM可以根据当前内存的分布情况为对象分配内存空间。分配的细节逻辑参考后面的章节。
初始化:内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)。
设置对象头:设置对象的基本信息(如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息),然后存放在对象的对象头中。
执行\
方法,通常来说,\ () 方法内包括的代码内容大概为:调用另一个 \ () 方法;对实例变量初始化;与其对应的构造方法内的代码。 如果构造方法是明确地从调用同一个类中的另一个构造方法开始,那它对应的 \
() 方法体内包括的内容为:一个对本类的\ () 方法的调用;对应用构造方法内的所有字节码。 如果构造方法不是通过调用自身类的其它构造方法开始,并且该对象不是 Object 对象,那 \
() 法内则包括的内容为:一个对父类\ () 方法的调用;对实例变量初始化方法的字节码;最后是对应构造子的方法体字节码。 如果这个类是 Object,那么它的 \
() 方法则不包括对父类 \ () 方法的调用。
对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
1、对象头
- 运行时数据:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- 类型指针:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。(并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据并不一定要经过对象本身,可参考对象的访问定位)
- 数组长度:如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
2、实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。
3、对齐填充
HotSpot虚拟机要求对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。
对象的访问定位
Java程序需要通过栈上的引用数据来操作堆上的具体对象。对象的访问方式取决于虚拟机实现,目前主流的访问方式有使用句柄和直接指针两种。
句柄:可以理解为指向指针的指针,维护指向对象的指针变化,而对象的句柄本身不发生变化;指针,指向对象,代表对象的内存地址。Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。优势是引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。

直接指针:如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而引用中存储的直接就是对象地址。优势是速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。

垃圾收集算法
垃圾收集包括两个过程,先是找出需要清除的对象,然后清除对象。
引用计数与可达性分析都是确定哪些对象需要被清除,后面三个算法都是决定如何清除对象。
引用计数法
引用计数法 给对象添加一个引用计数器,每次引用到它时引用计数器加1,当引用失效时引用计数器减1。当引用计数器为0时即表示当前对象可以被回收。 这个算法实现简单、判定效率也很高,但是无法处理循环引用的问题。
可达性分析算法
通过一系列的名为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连时,认为此对象是可回收的。其中可作为 GC Root 的对象包括以下几种:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中Native方法引用的对象
标记-清除算法
算法过程如其名,先标记确定需要被回收的对象,然后扫描一遍清除这些对象对应的内存。它实现简单,但是会导致较多的内存碎片,进而导致分配大对象时容易触发GC。
复制算法
复制算法将内存分为两个相等的区域,每次只使用一个区域,当GC触发时,就将所有不需要回收的对象复制到另一个区域,然后挥手原来的区域。优点是不需要考虑内存碎片的问题,但是需要损失一半的内存作为代价。
标记-整理算法
先确定需要回收的对象,然后将不需要回收的对象都像一端移动对齐,然后清除端边界的内存。
分代收集算法
分代收集算法是基于这样一个事实:不同的对象的生命周期(存活情况)是不一样的,如果将类似生命周期的对象位置于堆中相同的区域,然后对各个区域采用各自的策略进行回收可以提高 JVM 的执行效率。
当代商用虚拟机使用的都是分代收集算法:新生代对象存活率低,就采用复制算法;老年代存活率高,就用标记-清除算法或者标记-整理算法。
- 新生代(Young Generation):新生代的目标就是尽可能快速的收集掉那些生命周期短的对象,一般情况下,所有新生成的对象首先都是放在新生代的。新生代内存按照 8:1:1 的比例分为一个eden区和两个survivor(survivor0,survivor1)区,大部分对象在Eden区中生成。在进行垃圾回收时,先将eden区存活对象复制到survivor0区,然后清空eden区,当这个survivor0区也满了时,则将eden区和survivor0区存活对象复制到survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后交换survivor0区和survivor1区的角色(即下次垃圾回收时会扫描Eden区和survivor1区),即保持survivor0区为空,如此往复。特别地,当survivor1区也不足以存放eden区和survivor0区的存活对象时,就将存活对象直接存放到老年代。如果老年代也满了,就会触发一次FullGC,也就是新生代、老年代都进行回收。注意,新生代发生的GC也叫做MinorGC,MinorGC发生频率比较高,不一定等 Eden区满了才触发。
- 老年代(Old Generation):老年代存放的都是一些生命周期较长的对象,就像上面所叙述的那样,在新生代中经历了N次垃圾回收后仍然存活的对象就会被放到老年代中。此外,老年代的内存也比新生代大很多(大概比例是1:2),当老年代满时会触发Major GC(Full GC),老年代对象存活时间比较长,因此FullGC发生的频率比较低。
- 永久代(Permanent Generation):永久代主要用于存放静态文件,如Java类、方法等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如使用反射、动态代理、CGLib等bytecode框架时,在这种时候需要设置一个比较大的永久代空间来存放这些运行过程中新增的类。
内存分配与回收策略
线程安全
对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并非线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。解决这个问题有如下两个方案:
- 对分配内存空间的动作进行同步 实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性。
- 把内存分配的动作按照线程划分在不同的空间之中进行 即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB ,Thread Local Allocation Buffer),哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完,分配新的TLAB时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
对象优先在Eden分配
对象优先在Eden分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次MinorGC。现在的商业虚拟机一般都采用复制算法来回收新生代,将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。 当进行垃圾回收时,将Eden和Survivor中还存活的对象一次性地复制到另外一块Survivor空间上,最后处理掉Eden和刚才的Survivor空间。(HotSpot虚拟机默认Eden和Survivor的大小比例是8:1)当Survivor空间不够用时,需要依赖老年代进行分配担保。
大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。
长期存活的对象将直接进入老年代
当对象在新生代中经历过一定次数(MaxTenuringThreshold默认为15)的Minor GC后,就会被晋升到老年代中。为了更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
Android内存回收相关日志解读
可以参考官方文档:调查 RAM 使用情况