导图社区 深入理解Java虚拟机
深入理解Java虚拟机读书笔记。帮助大家快速获取该书籍重点内容。本图知识梳理清楚,非常实用,值得收藏。
编辑于2021-12-01 17:25:51深入理解Java虚拟机
走近Java
自动内存管理机制
Java内存区域与内存溢出异常
运行时数据区域
程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线 程所执行的字节码的行号指示器。
Java虚拟机栈
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的 生命周期与线程相同。
虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时 都会创建一个栈帧(Stack Frame[1])用于存储局部变量表、操作数栈、动态链接、方法出口 等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出 栈的过程。
两种异常状况:
如果线程请求的栈深度大于虚 拟机所允许的深度,将抛出StackOverflowError异常;
如果虚拟机栈可以动态扩展(当前大部 分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如 果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常
本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间 的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚 拟机使用到的Native方法服务。
与虚拟机栈一样,本地方法 栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
Java堆
Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就 是存放对象实例,几乎所有的对象实例都在这里分配内存。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”
从内存回收的角度来看
由于现在收集器基 本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;
再细致一点的有 Eden空间、From Survivor空间、To Survivor空间等。
从内存分配的角度来看,线程共享的 Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上 是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是 可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。
方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚 拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以 选择固定大小或者可扩展外,还可以选择不实现垃圾收集。
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。
Class文件中除了有类的版 本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于 存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规 范中定义的内存区域。这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError 异常出现
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓 冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储 在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著 提高性能,因为避免了在Java堆和Native堆中来回复制数据。
显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是 会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限 制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略 直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制), 从而导致动态扩展时出现OutOfMemoryError异常。
HotSpot虚拟机对象探秘
对象的创建
普通Java对象,虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一 个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没 有,那必须先执行相应的类加载过程,
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。
对象空间分配方式
“指针碰撞”(Bump the Pointer)
假设Java堆中内存是绝对规整的,所有用过的内 存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配 内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离
“空闲列表”(Free List)
如果Java堆中的内存并不是规整的,已使用的内存和空 闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记 录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录
对象并发创建线程安全问题
问题:可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来 分配内存的情况。
方案一:是对分配内存空间的动作进行同步处理 ——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
方案二:是把内存分 配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内 存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内 存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。 虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
接下来,虚拟机要对对象进行必要的设置
例如这个对象是哪个类的实例、如何才能找 到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对 象头(Object Header)之中。
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程 序的视角来看,对象创建才刚刚开始——<init>方法还没有执行,所有的字段都还为零。
对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)。
对象头
第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和 64bit,官方称它为“Mark Word”。
对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指 针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型 指针,换句话说,查找对象的元数据信息并不一定要经过对象本身
如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因 为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中 却无法确定数组的大小。
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。 由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说, 就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍), 因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问定位
句柄访问
Java堆中将会划分出一块内存来作为句柄池,reference中 存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信 息
优势:好处就是reference中存储的是稳 定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中 的实例数据指针,而reference本身不需要修改。
直接指针访问
Java堆对象的布局中就必须考虑如何放置访问类型数据的 相关信息,而reference中存储的直接就是对象地址
优势:使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销, 由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成 本。就 HotSpot而言,它是使用第二种方式进行对象访问的
实战:OutOfMemoryError异常
Java堆溢出
虚拟机栈和本地方法栈溢出
方法区和运行时常量池溢出
本机直接内存溢出
垃圾收集器与内存分配策略
概述
哪些内存需要回收?
Java内存 运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随 线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个 栈帧中分配多少内存基本上是在类结构确定下来时就已知的。因此 这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问 题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存,“内存”分配与回 收也仅指这一部分内存。
什么时候回收?
如何回收?
对象已死吗
在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一 件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径 使用的对象)。
引用计数算法
给对象中添加一个引用计数器,每当有 一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0 的对象就是不可能再被使用的。
可达性分析算法
主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中, 都是称通过可达性分析(Reachability Analysis)来判定对象是否存活的。
通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所 走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连 (用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
在Java语言中,可作为GC Roots的对象包括下面几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI(即一般说的Native方法)引用的对象。
再谈引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引 用链是否可达,判定对象是否存活都与“引用”有关。
在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
强引用:是指在程序代码之中普遍存在的,类似“Object obj=new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用:是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将 要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回 收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实 现软引用。
弱引用: 是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的 对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够, 都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引 用。
虚引用: 也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引 用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一 个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
生存还是死亡
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处 于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程
回收方法区
垃圾收集算法
“标记-清除”(Mark-Sweep)算法
首先标记出所有需要回收的对象,在标记完成后统一回收所有 被标记的对象
复制算法
将可用内存按容 量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是 对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指 针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原 来的一半,未免太高了一点。
现在的商业虚拟机都采用这种收集算法来回收新生代。将内存 分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor[1]。 当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最 后清理掉Eden和刚才用过的Survivor空间。
标记-整理算法
标记过程 仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存 活的对象都向一端移动,然后直接清理掉端边界以外的内存
分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法
这种算 法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆 分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代 中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付 出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间 对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
HotSpot的算法实现
枚举根节点
安全点
安全区域
垃圾收集器
Serial收集器
一个单线程的收集器,进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。“Stop The World”
ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之 外,其余行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX: PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对 象分配规则、回收策略等都与Serial收集器完全一样,在实现上,这两种收集器也共用了相 当多的代码。
并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状 态。
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能 会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。
Parallel Scavenge收集器
Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行 的多线程收集器
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点 是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到 一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总 消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚 拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高 吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不 需要太多交互的任务。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集 停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参 数。
由于与吞吐量关系密切,Parallel Scavenge收集器也经常称为“吞吐量优先”收集器。
Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整 理”算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。
Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集 器。
CMS收集器是基于“标记—清除”算法实现 的
Concurrent Mark Sweep收集器运行示意图
过程
初始标记(CMS initial mark)
并发标记(CMS concurrent mark)
重新标记(CMS remark)
并发清除(CMS concurrent sweep)
优点
并发收集、低停 顿
缺点
CMS收集器对CPU资源非常敏感。
在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资 源)而导致应用程序变慢,总吞吐量会降低。
CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。
CMS是一款基于“标记—清除”算法实现的收集器,意味着收集结束时会有大量空间碎片产生。
G1收集器
G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,G1是一款面向服务端应用的垃圾收集器。
特点
并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者 CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的 GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其 他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已 经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
空间整合:与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实 现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这 两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种 特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一 次GC。
可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关 注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一 个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实 时Java(RTSJ)的垃圾收集器的特征了。
过程
初始标记(Initial Marking)
并发标记(Concurrent Marking)
最终标记(Final Marking)
筛选回收(Live Data Counting and Evacuation)
理解GC日志
垃圾收集器参数总结
内存分配与回收策略
对象优先在Eden分配
大对象直接进入老年代
长期存活的对象将进入老年代
动态对象年龄判定
空间分配担保
虚拟机性能监控与故障处理工具
给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处 理数据的手段。
数据类型
运行日志
异常堆栈
GC日志
线程快照 (threaddump/javacore文件)
堆转储快照(heapdump/hprof文件)
JDK的命令行工具
jps:虚拟机进程状况工具
jps -l
jstat:虚拟机统计信息监视工具
jstat-gc 2764 250 20
jinfo:Java配置信息工具
jinfo -flag MaxHeapSize 8
jmap:Java内存映像工具
jhat:虚拟机堆转储快照分析工具
jstack:Java堆栈跟踪工具
HSDIS:JIT生成代码反汇编
JDK的可视化工具
JConsole:Java监视与管理控制台
VisualVM:多合一故障处理工具
调优案例分析与实战
虚拟机执行子系统
类文件结构
概述
无关性的基石
各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode) 是构成平台无关性的基石
“一次编写,到处 运行(Write Once,Run Anywhere)”
Class类文件的结构
魔数与Class文件的版本
常量池
访问标志
类索引、父类索引与接口索引集合
字段表集合
方法表集合
属性表集合
字节码指令简介
公有设计和私有实现
Class文件结构的发展
虚拟机类加载机制
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始 化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
类加载的时机
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)7个阶段。
类加载的过程
类加载器
类与类加载器
双亲委派模型
虚拟机字节码执行引擎
程序编译 与代码优化
高效并发
Java内存模型与线程
概述
衡量一个服务性能的高低好坏,每秒事务处理数(Transactions Per Second,TPS)是最重要的指标之一,它代表着一秒内服务端平均能响应的请求总数,而 TPS值与程序的并发能力又有非常密切的关系。
硬件的效率与一致性
处理器、高速缓存、主内存间的交互关系
子主题
Java内存模型
Java虚拟机规范中试图定义一种Java内存模型[1](Java Memory Model,JMM)来屏蔽掉各 种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访 问效果。
主内存与工作内存
Java与线程
线程的实现
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资 源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以 独立调度(线程是CPU调度的基本单位)
主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对 线程操作的统一处理,每个已经执行start()且还未结束的java.lang.Thread类的实例就代表 了一个线程。
在Java API中,一个Native方法往往意味着这个方法没有使用或无法使用 平台无关的手段来实现(当然也可能是为了执行效率而使用Native方法,不过,通常最高效 率的手段也就是平台相关的手段)
实现线程主要有3种方式
使用内核线程实现
内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支 持的线程
程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进 程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程
使用用户线程实现
从广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT),而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存 在的实现。
使用用户线程加轻量级进程混合实现
线程安全与锁优化