《深入理解Java虚拟机:JVM高级特性与最佳实践》读书笔记

时间:2022-12-27 19:17:17

第一部分 走进Java

一、走进Java

1、概述

  java广泛应用于嵌入式系统、移动终端、企业服务器、大型机等各种场合,摆脱了硬件平台的束缚,实现了“一次编写,到处运行”的理想

2、java技术体系结构

  按照功能来划分

  1. 包括以下几个组成部分:Java程序设计语言,各种硬件平台的java虚拟机,Java API类库,来自商业机构和开源社区的第三方Java类库,Class文件格式
  2. Java程序设计语言,java虚拟机,Java API类库统称为JDK,是用于支持java程序开发的最小环境
  3. Java API类库中的Java SE API子集和Java虚拟机统称为JRE,是支持java程序运行的基本环境

  按照技术所服务的领域划分分为4个平台

  1. Java Card:支持java小程序运行在java小内存设备(如智能卡)上的平台
  2. Java ME:支持Java程序运行在移动设备上的平台
  3. Java SE:支持面向桌面级应用的平台
  4. Java EE:支持使用多层架构的企业级应用的平台

第二部分  自动内存管理机制

二、内存区域和内存溢出异常

1、运行时数据区

  程序计数器

  • 记录的是正在执行的虚拟机字节码指令的地址,可以看成是当前线程所执行的字节码的行号指示器,每个线程都有一个独立的程序计数器,各条线程的程序计数器互不影响,独立存储,这类内存区域成为“线程私有的内存”。
  • 此内存区域是唯一在虚拟机规范中没有OutOfMemoryError的情况的区域

  Java虚拟机栈

  • 同程序计数器一样,也是线程私有的。每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
  • 每一个方法从调用直至执行完成的过程,都对应着一个栈帧在虚拟机栈中入栈和出栈的过程。
  • 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
  • 如果请求的栈深度超过虚拟机锁允许的深度,将抛出*Error异常。如果拓展无法申请到足够的内存,将抛出OutOfMemoryError异常。

  本地方法栈

  • 为虚拟机使用的native方法服务,和虚拟机栈一样,本地方法栈也会抛出*Error和OutOfMemoryError异常。

  Java堆

  • Java堆是所有线程共享的一块内存区域,用来存放对象实例,几乎所有的对象实例都在这里分配。
  • Java堆是垃圾回收的主要区域,采用分代收集算法。
  • Java堆分为新生代和老年代,在细致一点分为Eden,From Survivor,To Survivor空间。
  • 如果堆中无法完成对象实例的内存分配,且堆也无法扩展时,将抛出OutOfMemoryError异常。

  方法区

  • 是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,HotSpot虚拟机的设计团队把GC分代收集扩展至方法区,或者说使用永久代来代替方法区。
  • 在目前已经发布的JDK1.7的HotSpot中,已经把原本放在永久代的字符串常量池移出了。当方法区无法满足内存的分配需求时,将抛出OutOfMemoryError异常。

  运行时常量池

  • 是方法区的一部分,Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池存放。
  • 运行时常量池相对于Class文件常量池,具有动态性,运行期间也可以将新的常量放入常量池,比如String类的intern()方法。
  • 当运行时常量池无法申请到更多的内存时,将会抛出OutOfMemoryError异常。

  直接内存

  • 并不是运行时区域的一部分,JDK 1.4加入的NIO 它可以使用Native函数库直接分配堆外内存,然后通过Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。

2、HotSpot虚拟机对象探秘

  对象的创建

  • 当遇到new指令时,先判断这个类是否被加载、解析、初始化过,如果没有,先执行相应类的加载过程。
  • 类加载检查通过后,为新生对象分配内存,如果Java堆内存是规整连续的,采用“指针碰撞”的分配方式,如果是不连续规整的,采用“空闲列表”分配方式。内存是否规整取决于垃圾收集器是否带有压缩整理功能。
  • Serial,ParNew等带有Compact过程的收集器,采用的分配算法是“指针碰撞”。而CMS这种基于Mark-Sweep算法的收集器,通常采用“空闲列表”分配方式。
  • 创建对象涉及到分配内存和指针指向两个操作,不是原子性的,不是线程安全的。针对这个问题,有两个解决办法:1是采用CAS加上失败重试来保证操作的原子性。2是采用TLAB(Thread Local Allocation Buffer)策略,在Java堆中预先为每一个线程分配一小块内存,称为TLAB(Thread Local Allocation Buffer),哪个线程要分配内存就在各自的TLAB上进行内存的分配,只有TLAB用完进行新的TLAB的分配时才需要同步锁定,虚拟机是否使用TLAB,可以通过 -XX:+/- UseTLAB
  • 内存分配完成后,需要对对象头进行设置,包括这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。
  • 最后执行init方法,把对象按照程序员的意愿进行初始化。这样一个真正可用的对象才算完全生产出来。

  对象的内存布局

  • 分为三块区域,对象头(Header)、实例数据(Instance Data)、对齐补充(Padding)。
  • 对象头,存储对象自身的运行时数据,如哈希码、对象的GC分代年龄、锁状态标志、偏向线程ID、偏向时间戳,这部分数据的长度在32位和64位虚拟机中分别为32bit和64bit。
  • 另一个部分是类型指针,虚拟机通过这个对象来确定这个对象是哪个类的实例。

  对象的访问定位

  • Java程序需要通过栈上的reference数据来操作堆中的具体对象,具体实现有两种方式:使用句柄和直接指针两种。
  • 使用句柄:Java堆中划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,句柄中包括了对象的实例数据和类型数据各自的地址信息。最大好处是当对象修改时,reference本身不需要修改,因为reference中存储的是稳定的句柄地址

                                                    《深入理解Java虚拟机:JVM高级特性与最佳实践》读书笔记

  • 直接指针:reference中存储的直接就是堆中的对象地址,堆对象的布局中需要考虑如何放置访问类型数据的相关信息。最大好处是速度更快,节省了一次指针定位的开销,HotSpot就采用的直接指针方式。

                         《深入理解Java虚拟机:JVM高级特性与最佳实践》读书笔记

3、OutOfMemoryError异常

        堆溢出

  • 不断创建对象,保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,达到最大堆的容量限制后就会产生内存溢出异常。
  • -Xms20m 堆的最小值;-Xmx20m 堆的最大值;-XX:+HeapDumpOnOutOfMemoryError  内存溢出异常时Dump出当前的内存堆转储快照以便日后分析

        虚拟机栈和本地方法栈溢出

  • -Xss 栈容量

        方法区和运行常量池溢出

  • 多次调用String.intern()方法可以产生内存溢出异常。JDK 1.6之间,可以通过 -XX:PermSize  和 -XX:MaxPermSize  限制永久代大小,从而达到限制方法区大小的目的

        本地直接内存溢出

  • 可以通过 -XX:MaxDirectMemorySize 指定。如果不指定,则默认和Java堆最大值(-Xmx 指定)一样

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

 1、对象已死吗?如何确定对象是否还“活着”

  引用计数器方法

  • 给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1,当引用失效时,计数器就减1。
  • 优点是判定简单,效率也很高。缺点是无法解决相互循环引用的问题

  可达性分析方法

  • 通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连时,说明这个对象是可回收的。
  • Java语言中,可作为GC Roots的对象包括以下几种:虚拟机栈中引用的对象,方法区中类静态属性引用的对象,方法区中常量引用的对象,本地方法栈中JNI引用的对象。

   再谈引用

  • JDK1.2 之后把引用分为了四种:强引用、软引用、弱引用、虚引用
  • 强引用:只要强引用还存在,就不会被垃圾回收器回收。类似 Object o=new Object()
  • 软引用:指一些有用但并非必须的对象,在系统将要发生内存溢出的时候,会将这部分对象回收。SoftReference 类来实现软引用
  • 弱引用:被弱引用关联的对象只能生存到下一次垃圾回收。WeakReference 类来实现弱引用
  • 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间造车影响,也无法通过虚引用取得对象的引用。一个对象设置虚引用的唯一目的是在被垃圾回收的时候收到一个系统通知

   对象被回收的过程

  • 当对象进行可达性分析没有与GC Roots相连的引用链,将会被第一次标记,并根据是否需要执行finalize()方法进行一次筛选,对象没有重写finalize()或者虚拟机已经调用过finalize(),都被视为不需要执行
  •  如果对象有必要执行finalize,会被放入到F-Queue队列中,并在稍后由虚拟机自动创建的低优先级的Finalizer线程去触发它,并不保证等待此方法执行结束。
  • 如果对象在finalize()方法执行中,重新和GC Roots产生了引用链,则可以逃脱此次被回收的命运,但finalize()方法只能运行一次,所以并不能通过此方法逃脱下一次被回收
  • 笔者不建议使用这个方法,建议大家完全忘掉这个方法的存在。

   回收方法区

  • 主要包括废弃常量和无用类的回收。判断类无用:类的实例都被回收,类的ClassLoader被回收,类的Java.Lang.Class对象没有在任何地方引用。满足这三个条件,类才可以被回收(卸载)
  •  HotSpot虚拟机通过 -Xnoclassgc 参数进行控制是否启用类卸载功能。在大量使用反射、动态代理、CGLib等框架,需要虚拟机具备类卸载功能,避免方法区发生内存溢出

2、垃圾回收算法

  标记-清除

  • 先标记出所有要回收的对象,在标记完成后统一进行对象的回收。有两个不足:

       1 是效率问题,标记和清除的效率都不高。

       2 是空间问题,会产生大量不连续的内存碎片,碎片太多会都导致大对象无法找到足够的内存,从提前触发垃圾回收。

  复制算法

  • 新生代分为一个Eden,两个Survival空间,默认比例是8:1。回收时,将Eden和一个Survival的存活对象全部放入到另一个Survival空间中,最后清理掉刚刚的Eden和Survival空间
  •  当Survival空间不够时,由老年代进行内存分配担保

  标记-整理

  • 根据老年代对象的特点,先标记存活对象,将存活对象移动到一端,然后直接清理掉端边界以外的对象

  分代收集

  • 新生代采用复制算法,老年代采用标记-删除,或者标记-整理算法。

3、HotSpot算法实现

  枚举根节点实现

  • 可达性分析时会进行GC停顿,停顿所有的Java线程。
  • HotSpot进行的是准确式GC,当系统停顿下来后,虚拟机有办法得知哪些地方存在着对象引用,HotSpot中使用一组称为OopMap的数据结构来达到这个目的

  安全点

  • HotSpot没有为每个指令都生成OopMap,只在特定的位置记录这些信息,这些位置称为安全点。安全点的选定不能太少,也不能太频繁,安全点的选定以“是否让程序长时间执行”为标准
  • 采用主动式中断的方式让所有线程都跑到最近的安全点上停顿下来。设置一个标志,各个程序执行的时候轮询这个标志,发现中断标志为真时自己就中断挂起

  安全区域

  • 解决没有分配Cpu时间的暂时不执行的程序停顿。

4、垃圾收集器

              如果两个收集器之间有连线,说明可以搭配使用。没有最好的收集器,也没有万能的收集器,只有对应具体应用最合适的收集器。

     《深入理解Java虚拟机:JVM高级特性与最佳实践》读书笔记

  Serial 收集器

  • 新生代收集器,单线程回收。优点在于,简单而高效,对于运行在Client模式下的虚拟机来说是一个很好的选择(比如用户的桌面应用)
  • 参数 -XX:UseSerialGC,打开此开关后,使用Serial+Serial Old的收集器组合进行内存回收

  ParNew收集器

  • 新生代收集器,Serial的多线程版本,除了Serial收集器之外,只有它能与CMS收集器配合工作。
  • -XX:+UseConcMarkSweepGC 选项后默认的新生代收集器,也可以使用 -XX:+UseParNewGC 选项来强制指定它
  • ParNew收集器在单CPU的环境中,效果不如Serial好,随着CPU的增加,对于GC时系统资源的利用还是很有效的。
  • 默认开启的收集线程数和CPU数相等,可以使用 -XX:ParallelGCThreads 指定

  Parallel Scavenge收集器

  • 新生代收集器,并行收集器,复制算法,和其他收集器不同,关注点的是吞吐量(垃圾回收时间占总时间的比例)。提供了两个参数用于控制吞吐量。
  • -XX:MaxGCPauseMillis,最大垃圾收集停顿时间,减少GC的停顿时间是以牺牲吞吐量和新生代空间来换取的,不是设置的越小越好
  • -XX:GCTimeRatio,设置吞吐量大小,值是大于0小于100的范围,相当于吞吐量的倒数,比如设置成99,吞吐量就为1/(1+99)=1%。
  • -XX:UseAdaptiveSizePolicy ,这是一个开关参数,打开之后,就不需要设置新生代大小(-Xmn)、Eden和Survival的比例(-XX:SurvivalRatio)、 晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数,收集器会自动调节这些参数。

  Serial Old 收集器

  • 单线程收集器,老年代,主要意义是在Client模式下的虚拟机使用。在Server端,用于在JDK1.5以及之前版本和Parallel Scavenge配合使用,或者作为CMS的后备预案。

  Palallel Old 收集器

  • 是Parallel Scavenge的老年代版本。在注重吞吐量的场合,都可以优先考虑Parallel Scavenge 和Palallel Old 配合使用

  CMS 收集器

  • Concurrent Mark Sweep,是一种以获取最短回收停顿时间为目标的收集器,尤其重视服务的响应速度。基于标记-清除算法实现。
  • 分为四个步骤进行垃圾回收:初始标记,并发标记,重新标记,并发清除。只有初始标记和重新标记需要停顿。
  • 初始标记只是标记一下GC Roots能直接关联到的对象,速度很快。并发标记就是进行GC Roots的Tracing。
  • 重新标记为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间会比初始标记阶段稍长,远比并发时间短。
  • 耗时最长的并发标记和并发清除过程中,处理器可以与用户线程一起工作。
  • 它并不是完美的,有如下三个比较明显的缺点:

     1、垃圾回收时会占用一部分线程,导致系统变慢,总吞吐量会降低。

     2、无法处理浮动垃圾,需要预留足够的内存空间给用户线程使用,可以通过 -XX:CMSInitiatingOccupancyFraction 参数控制触发垃圾回收的阈值。

       如果预留的内存无法满足程序需要,就会出现“Concurrent Mode Failure”失败,这时将启动应急预案,启用Serial Old 进行垃圾回收,停顿时间会变长

       所以-XX:CMSInitiatingOccupancyFraction 参数的值设置的太高,会导致频繁“Concurrent Mode Failure”失败,性能反而降低。

     3、标记-清理,容易产生内存碎片。-XX:+UseCMSCompactAtFullColletion 开启碎片整理功能,默认开启,-XX:CMSFullGCsBeforeCompaction,控制多少次不压缩的FullGC之后来一次带压缩的

  G1 收集器

  • 包括新生代和老年代的垃圾回收。和其他收集器相比的优点:并行和并发,分代收集,标记-整理,可预测的停顿。垃圾回收分为以下几个步骤:
  • 初始标记:标记GC Roots能够直接关联到的对象,这阶段需要停顿线程,时间很短
  • 并发标记:进行可达性分析,这阶段耗时较长,可与用户程序并发执行
  • 最终标记:修正发生变化的记录,需要停顿线程,但是可并行执行
  • 筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来执行回收计划

5、内存分配和回收策略

  • 对象优先在Eden分配,当新生区没有足够的内存是,通过分配担保机制提前转移到老年代中去
  • 大对象直接进入老年代。大对象是指需要大量连续内存空间的对象,虚拟机提供了参数 -XX:PretenureSizeThreshold(只对Serial,PerNew两个回收器起效),令大于这个值得对象直接在老年代分配,避免了Eden和两个Survival之间发生大量的内存复制。
  • 长期存活的对象将进入老年代。虚拟机给每个对象定义了对象年龄计数器(Age),如果对象在Eden出生,经过第一次Minor GC后依然存活,并且能被Survival容纳的话,将被移动到Survival,对象年龄设为1。对象在Survival中每熬过一次Major GC,年龄就增加1,达到一定程度(默认是15),就会被晋升到老年代。对象晋升老年代的阈值,可以通过参数-XX:MaxTenuringThreShold 指定
  • 动态对象年龄判断。如果在Survival空间中相同年龄所有对象的大小综合超过了Survival空间的一半,年龄大于等于这个年龄的对象都会被晋升到老年代。无需等待年龄超过MaxTenuringThreShold指定的年龄
  • 空间分配担保。只要老年代的连续空间大于新生代对象总和或者历次晋升的平均大小,就进行Major GC,否则进行Full  GC。

第五部分  高效并发

十二、Java内存模型与线程

 1、硬件的效率与一致性

  • 完成计算任务,处理器必须和内存交互才能完成,比如读取运算数据,写入计算结果等。这个I/O操作是很难消除的。计算的处理器和存储设备的运算速度有几个数量级的差距。所以现代计算机加入了一层读写速度尽可能接近处理器的高速缓存
  • 高速缓存解决了处理器和内存的速度矛盾,却引入了新的问题:内存一致性。多处理器系统中,各个处理器都有自己的高速缓存,又同时共用内存。为了解决这一问题,在读写内存时需要遵循缓存一致性协议。
  • 处理器会对输入的代码进行乱序执行优化,类似的,Java虚拟机也存在着指令重排序优化。

2、Java内存模型

Java内存模型用于屏蔽掉各种硬件和操作系统的内存访问差异,c和c++等直接使用物理硬件和操作系统的内存模型。

Java内存模型规定,所有的变量都存储在主内存,每条线程有自己的工作内存,工作内存中保存了该线程使用到的变量的拷贝副本,线程对变量的所有操作都必须在工作内存中进行,线程间变量值得传递需通过主内存来完成

内存间交互操作:lock,unlock,read,load,store,write,use(使用),assign(赋值)

对于volatile变量的特殊规则:保证了可见性,每次使用之间都要先从内存中刷新成最新值

关于long和double类型变量的特殊规则:允许虚拟机将没有被volatile变量修饰的64位数据的读写操作划分为两次32位的操作来进行。这点就是long和double的非原子性协定