读书笔记之《深入理解Java虚拟机(JVM高级特性与最佳实践》)
学而不思则罔,思而不学则殆。 —— 孔子本篇带来的是周志明老师编写的《深入理解Java虚拟机:JVM高级特性与最佳实践》,十分硬核!
微信公众号已开启,菜农曰,没关注的同学们记得关注哦!
文章图片
全书共分为 5 部分,围绕内存管理、执行子系统、程序编译与优化、高效并发等核心主题对JVM进行了全面而深入的分析,深刻揭示了JVM工作原理。
全书整体5个部分,十三章,共 358929 字。整体结构相当清晰,以至于写读书笔记的时候无从摘抄(甚至想把全书复述一遍),以下是全书第二部分的内容,望读者细细品尝!
一、第一部分 走进Java 第一部分介绍了关于 Java 的技术体系与发展史,谈及未来。该部分内容不做摘抄,直接进入核心主题。
二、第二部分 自动内存管理机制
Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。
文章图片
第二章 Java内存区域与内存溢出异常
对于 Java 程序员来说是幸福的也是可悲的,在虚拟机自动内存管理机制的帮助下不需要为每一个 new 操作去写配对的 delete/ free 代码,不容易出现内存泄露和内存溢出问题,但是在内存管理领域中,C或 C++,既是拥有最高权力的 "皇帝" 又是从事最基础工作的 "劳动人民"。
1)运行时数据区域 Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域.
文章图片
- 程序计数器
为了线程切换后能够恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,线程私有。
此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
1. 虚拟机栈 与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储 局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
2. 本地方法栈 与虚拟机栈十分相似,主要的区别在于虚拟机栈是为虚拟机执行 Java 方法服务,而本地方法栈是为虚拟机使用到的 Native 方法服务。
与虚拟机栈一样的是:本地方法栈也会抛出 StackOverFlowError 和 OutOfMemoryError 异常
3. Java 堆 Java 堆是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都会在这里进行内存分配。
Java 堆是垃圾收集器管理的主要区域,因此很多时候也会称为 GC 堆 ,因此还可以细分为:新生代和老年代,而新生代中又可以细致为 Eden空间、From Survivor 空间、To Survivor 空间等。
4. 方法区 方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
永久代与方法区,本质上两者并不等价
HotSpot 虚拟机设计团队会利用 永久代 来实现方法区,这样 HotSpot 的垃圾收集器就可以像 Java 堆那样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小,还可以选择不实现垃圾收集。
并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是必要的2)扩展说明
- 运行时常量池
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备 动态性(并不要求常量一定只有编译期才能产生,运行期间也可以将新的常量存入池中)
运行时常量池是 方法区 的一部分,当常量池无法再申请到内存时也会抛出 OutOfMemoryError 异常。
- 直接内存
在Java1.4 引入的 NIO,是一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,可以使用 Native 函数库直接分配 堆外内存,避免了在 Java 堆和 Native 堆中来回复制数据,可以在一些场景中显著提高性能。
直接内存的分配不会受到 Java 堆大小的限制,但还是会受到本机总内存大小以及处理器寻址空间的限制。
3)HotSpot 虚拟机对象探秘
- 对象的创建
众所周知,对象的创建通常是使用 new 关键字生成的虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的 符号引用,并且检查该符号引用是否已经被加载、解析和初始化(类加载的过程)。如果没有,则必须先执行相应的类加载过程。
在类加载检查通过后,虚拟机将为新生对象分配内存,对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
分配内存仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配的方式称为 指针碰撞。(虚拟机必须维护一个列表,记录哪些内存块是可用的,在分配的时候就从列表中找到一块足够大的空间划分给对象实例,这种分配方式称为空闲列表)
当然,创建对象还需要考虑 并发问题,有可能给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存。解决该问题有两种方案:
- 对分配内存空间的动作进行同步处理——实际上虚拟机采用 CAS配上失败重试的方式保证更新操作的原子性
- 把内存分配的动作按照线程划分在不同的空间之中,即每个线程在Java堆中预先分配一小块内存,称为 本地线程分配缓冲(TLAB)。
到此,一个新的对象产生了,但是由于还没有执行
- 对象的内存布局
对象头:
- 用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 bit和 64bit,官方称为 Mark Word。
文章图片
- 另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。这部分存储顺序会受到虚拟机分配策略参数和字段在 Java 源码中定义顺序的影响。
对齐填充:
这部分并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
- 对象的访问定位
建立对象是为了使用对象,我们的Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象目前主流的对象访问方式有两种
- 句柄访问。Java堆中会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的具体地址信息。
文章图片
- 直接指针访问。Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的就是对象地址。
文章图片
这两种对象访问方式各有优势:
- 句柄访问:reference中存储的最稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要改变。
- 直接指针访问:速度更快,节省了一次指针定位的时间开销
- 堆内存溢出
具体情况具体分析,可以借助内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
如果是内存泄露,那么需要找到泄露对象是通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动回收它们的。
如果是内存溢出,那就需要检查虚拟机的堆参数(-Xms 和 -Xmx),与机器物理内存对比看看是否还可以调大,从代码上检查是否存在某些对象生命周期过长,持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
- 虚拟机栈和本地方法栈溢出
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常
- 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常
- 方法区和运行时常量池溢出
方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。
在经常动态生成大量Class的应用中,需要特别注意类的回收状况。
这类场景除了使用了CGLib字节码增强和动态语言之外,常见的还有:大量JSP或动态产生JSP文件的应
用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器
加载也会视为不同的类)等。
- 本机直接内存溢出
-XX: MaxDirectMemorySize
指定。如果不指定,则默认与 Java 堆最大值(-Xmx)一样。在 DirectByteBuffer 分配内存时也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算机得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是:
unsafe.allocateMemory()
由 DirectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果读者发现 OOM 之后 Dump 的文件很小,而程序中又直接或间接使用了 NIO,那就可以考虑检查是不是这方面的问题第三章 垃圾收集器与内存分配策略
1)对象已死吗 在垃圾收集器工作之前需要确定这些对象中哪些是 存活 的,哪些已经 死去
- 引用计数法
缺陷:两个无用对象出现相互引用而无法回收。
- 可达性分析法
可作为 GC Roots 对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区种类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(Native 方法)引用的对象
- 引用种类
- 强引用:普遍存在的引用(通过 new 方式),只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象
- 软引用:一些还有用但非必须的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
- 弱引用:强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否够用,都会回收掉只被弱引用关联的对象
- 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是在这个对象被收集器回收时收到一个系统通知
- 生存还是死亡
即使在可达性分析算法中不可达的对象,也并非是 非死不可 的宣告一个对象死亡,至少要经过两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行
finalize()
方法,当对象没有覆盖 finalize()
方法,或已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。如果这个对象判定为有必要执行
finalize()
方法,那么这个对象会被放置在一个叫做 F-Queue 的队列中,并在稍后由一个虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合。
注意:任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finallize() 方法不会被再次执行。
- 回收方法区
永久代的垃圾收集主要回收两部分内容: 废弃常量 和 无用的类。
类需要同时满足下面 3 个条件才算是 无用的类:
- 该类的所有实例都已经被回收,也就是 Java 堆中不存在该类的任何实例
- 加载该类的 ClassLoader 已经被回收
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无论在任何地方通过反射访问该类的方法
- 标记 - 清除算法
文章图片
不足之处:效率问题 和 空间问题
- 复制算法
文章图片
不足之处:将内存缩小为了原来的一半,代价有点高了
现在商业虚拟机都采用这种收集算法来回收新生代。将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。(默认Eden和Survivor的大小比例是8∶1)
- 标记-整理算法
文章图片
- 分代收集算法
3)HotSpot 的算法实现
- 枚举根节点
进行——这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不
可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保
证。这点是导致GC进行时必须停顿所有Java执行线程(Sun将这件事情称为“Stop The World”)的其
中一个重要原因,即使是在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
虚拟机应当是有办法直接得知哪些地方存放着对象引用。在HotSpot的实现中,是使用一组称 OopMap
的数据结构来达到这个目的。
- 安全点
的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。
因此 HotSpot 会在 特定的位置 记录这些信息,这些位置称为 安全点 ,程序执行时并非在所有地方都能停顿下来开始 GC ,只有在到达安全点才能暂停。
安全点的选定基本上是以是否具备让程序长时间执行的特征选定的。
长时间执行最明显的特征就是指令序列复用(方法调用、循环跳转、异常跳转等)在 GC 发生时要让所有线程都跑到安全点上再停顿下来有两种方式:
- 抢先式中断:不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程都中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。
- 主动式中断:当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。
- 安全区域
但是在程序不执行的时候(线程Sleep或Blocked状态)这时候线程就无法响应 JVM 的中断请求,这个时候就需要 安全区域 来解决。安全区域是扩展了的 Safepoint。
在线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region,那样,当这段时间里 JVM 要发起 GC 时,就不用管标识自己为 Safe Region状态的线程了,在线程要离开 Safe Region 时,它要检查系统是否已经完成了根节点枚举(或者整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开 SafeRegion 的信号为止。
4)垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。两个概念:
- 并行(Parallel):指多条垃圾收集线程并行工作,此时用户线程仍然处于等待状态
- 并发(Concurrent):用户线程与垃圾收集线程同时执行(不一定是并行的,也可以是交替执行),用户程序继续运行,而垃圾收集程序则运行在另一个 CPU 上
文章图片
在 HotSpot 中大致存在着作用于不同分代的收集器,如果两个收集器之间存在连线,说明可以搭配使用。
- Serial 收集器
文章图片
- ParNew 收集器
文章图片
- Parallel Scavenge 收集器
- CMS 等收集器的关注点在于 尽可能地缩短垃圾收集时用户线程的停顿时间
- Parallel Scanvenge 则 尽可能达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 /(运行用户代码时间 +垃圾收集时间)
Parallel Scavenge收集器无法与CMS收集器配合工作,如果新生代选择了Parallel Scavenge收集器,老年代只能选择 Serial Old 收集器
- Serial Old 收集器
文章图片
- Parallel Old 收集器
文章图片
- CMS 收集器
CMS收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:
- 初始标记(需 Stop The World)
- 并发标记
- 重新标记(需 Stop The World)
- 并发清除
文章图片
CMS 收集器明显的3个缺陷:
- CMS 收集器对 CPU 资源非常敏感
- CMS 收集器无法处理浮动垃圾,可能会出现 "Concurrent Mode Failure" 失败而导致另一次 Full GC发生
- CMS 收集器会产生大量的内存碎片
浮动垃圾:由于 CMS 并发清理阶段用户线程还在运行这,伴随程序运行自然就还有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理,只好在下次 GC 时在清理掉。
- G1 收集器
- 并行与并发
- 分代收集
- 空间整合:G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存
- 可预测停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒
- 初始标记
- 并发标记
- 最终标记(需要 Stop The World)
- 筛选回收
文章图片
5)内存分配与回收策略
- 对象优先在Eden分配
Minor GC 和 Full GC 的区别:
- 新生代GC(Minor GC):发生在新生代的垃圾收集动作,因为 Java 对象太多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快
- 老年代GC(Major GC/ Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scanvenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)。Major GC 的速度一般会比Minor GC 慢10倍以上
- 大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组
- 长期存活的对象将进入老年代
- 动态对象年龄判定
- 空间分配担保
第四章 虚拟机性能监控与故障处理工具
1)JDK 的命令行工具
文章图片
这些工具都异常小巧,因为这些命令行工具大多数是
jdk/lib/tools.jar
类库的一层薄包装,主要功能代码是在 tools 类库中实现的。文章图片
- jps:虚拟机进程状况工具
主要选项:
文章图片
- jstat:虚拟机统计信息监视工具
主要选项:
文章图片
- jinfo:Java 配置信息工具
- jmap:Java 内存映像工具
主要选项:
文章图片
- jhat:虚拟机堆转储快照分析工具
- jstack:Java 堆栈跟踪工具
线程快照就是当前虚拟机中每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。
主要选项:
文章图片
2) JDK 的可视化工具
- JConsole:Java 监视与管理控制台
- VisualVM:多合一故障处理工具
这篇咱们主要是针对 《深入理解Java虚拟机:JVM高级特性与最佳实践》 第二部分做了相关的读书笔记,一方面是因为篇幅原因,接下来会针对每部分分别发布。另一方面是为了让读者有充足的时间进行消化,毕竟一口吃不成大胖子 ~ 请读者慢慢阅读,静待下部分读书笔记的出炉!
不要空谈,不要贪懒,和小菜一起做个 吹着牛X做架构 的程序猿吧~点个关注做个伴,让小菜不再孤单。咱们下文见!
今天的你多努力一点,明天的你就能少说一句求人的话!
【读书笔记之《深入理解Java虚拟机(JVM高级特性与最佳实践》)】 微信公众号:菜农曰,没关注的同学们记得关注哦!
推荐阅读
- Android源码解析之Dalvik虚拟机简要介绍
- #|JavaScript高级(ES6)
- 给我一个六月我将学会游泳之第四节课
- 易家文化-书香进万家第2期户外经典读书会
- Unity3D学习笔记8——GPU实例化(3)
- 投稿|三倍赔偿金,加码新能源电池龙头之争
- JUC源码学习笔记1——AQS和ReentrantLock
- 2016年7月8日|2016年7月8日 王倩 第四幅 博赞思维导图管理师认证班 读书笔记
- 《金文成〈中庸〉学习笔记300》
- 2018年4月“强制读书群”书单