读《深入理解java虚拟机》

170 阅读21分钟
感谢周老师这本书,带我走进java虚拟机的世界,本文记录学习该书的一些要点,希望大家购买原书观看。

第一章 走近Java

世界上并没有完美的程序,但我们并不因此而沮丧,因为写程序本来就是一个不断追求完美的过程。

Java为何得到广泛认可?

1、面向对象的思想,相对于面向过程的进步

2、语言结构严谨,更能写出标准化代码

3、克服硬件平台限制,跨平台(计算机艺术手法--中间层思想--jvm)

4、屏蔽硬件差异,JMM模型提供相对安全的内存管理和访问机制

5、热点代码检测,运行时编译及优化

6、活跃的第三方生态等。

目前,最牛掰的是市场,占绝对地位,但是也在受新技术的挑战。

java技术的未来

1、模块化

2、混合多语言

3、多核并行

第二章 自动内存管理机制

JAVA相对于C/C++多出来的就是内存的分配、回收不用开发人员所关心,交由JVM来处理,通过内存动态分配和垃圾收集技术实现。

开发人员为啥要熟练掌握JMM?

因为内存分配、回收的权力交给虚拟机,并不代表不会发生内存泄漏、溢出等方面的问题,一旦发生了问题,如果不了解虚拟机内存各个区域怎样使用的,那将很难排查问题,因此在工作过一段时间后,必须掌握该知识,即使很少机会去优化JVM,但是这是Java开发的必备技能,需要未雨绸缪;另外面试中必问。

jvm运行时数据区域如下图:

jvm1.jpg 程序计数器:是一块较小的内存空间,用于记录当前线程要执行的下一条命令的地址,它是线程私有的,也正如此在线程切换后能恢复到正 确的执行位置;注意如果线程执行的是native方法,则计数器的值为空。该区域是jvm中唯一一个不会OOM的区域。

虚拟机栈(java虚拟机栈): 线程私有的,针对的是Java方法执行的内存模型:执行方法的过程就是栈帧入栈到出栈的过程。

该区域有两个异常:

StackOverflowError:请求的栈深度大于虚拟机所允许的深度;

OutOfMemoryError:无法申请到足够内存时,报错。

本地方法栈:该区域与虚拟机栈功能类似,只不过虚拟机栈为虚拟机执行java方法服务的,而本地方法栈为Native方法服务的。

:是Java虚拟机所管理的内存中最大的一块,并且被所有线程共享。几乎所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,对象实例堆上分配不那么绝对了。现在垃圾收集器,基本采用分代收集算法,并且进一步划分内存,Eden空间、From Survivor空间、To Survior空间等,就是为了更快分配内存,更好的回收内存。

修改堆大小参数: -Xmx -Xms

方法区:也叫永久代(PermGen),jdk8以后为元空间(Metaspace),该区域也是线程共享的。它用于存储被虚拟机加载的类信息、常量(运行时常量池中,动态的)、静态变量、即时编译器编译的代码等数据。

为什么jdk8以后改为元空间?

  1. 兼容JRockit VM,该虚拟机没有永久代
  2. 元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。

当报OOM如何排查解决问题?

首先,需要分析是内存泄漏还是内存溢出(借助分析工具),如果是泄漏则需要优化代码,而如果是溢出就调整JVM参数分配更多的内存。

对象创建过程

  1. 遇到new指令,先校验类是否已经被加载,如果没有加载,则先类加载;
  2. 通过指针碰撞(内存绝对归整)或者空闲列表(内存不连续时)进行分配内存;
  3. 虚拟机将分配到的内存空间都初始化为零值(不包含对象头);
  4. 虚拟机对对象进行必要设置,例如对象是哪个类的实例,如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些信息放在对象头中。
  5. 执行init方法,把对象按照程序员的意愿初始化

第三章 垃圾收集器与内存分配策略

GC其实需要完成三件事情:

  1. 哪些内存需要回收?无用的内存需要回收,怎么确定是无用的?

  2. 什么时候回收?

  3. 如何回收?

如何确定对象已死--变成垃圾

引用计数

优点:简单高效,在python、游戏脚本中有使用。

缺点:较难解决循环引用问题。

可达性分析

从GC Roots的对象作为起点,判断是否可以到达这个对象,不可到达则为垃圾。 GC Roots的对象包括下面几种:

  1. 虚拟机栈中引用的对象;
  2. 本地方法栈中JNI(即native方法)引用的对象;
  3. 方法区中类静态属性引用的对象;
  4. 方法区中常量引用的对象;

圾收集算法

复制算法

优点:实现简单,当对象存活不高时运行效率高;因此适合回收新生代;

缺点:浪费空间

标记-清除

首先,标记出所有需要回收的对象,然后在统一回收;用于老年代;

缺点:效率低,而且导致内存不连续;

标记-整理

首先,标记存活对象,然后都像一端移动,最后清理掉端边界以外的内存。

垃圾收集器

HotSpot虚拟机中的垃圾收集器.png

Serial收集器

一个单线程收集器,一旦进行GC,就会Stop the world,虚拟机开发团队一直在为减少停顿时间而努力。在Client模式下,默认新生代收集器。

ParNew收集器

其实就是Serial收集器的多线程版本,因此也会STW,那么在新生代到底该选哪个收集器?如果是1个cpu则优先考虑serial,多线程的parnew还有线程切换性能损耗,现如今服务器都是多核,因此ParNew更常用。 重要的一点:当CMS作为老年代的收集器,新生代除了Serial外,目前只有它能与CMS收集器配合工作。

Parallel Scavenge收集器(吞吐量优先收集器)

新生代,采用复制算法的收集器(并行多线程)。与CMS不同的是,CMS的关注点是尽可能缩短垃圾收集时用户线程的停顿时间;二Parallel Scavenge更关注可控制的吞吐量(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间))。 适用于:后台运算而不需要太多交互的场景中。

Serial Old收集器

老年代的单线程收集器,有两个用途:

  1. JDK1.5以及之前的版本中,常与Parallel Scavenge收集器搭配使用;
  2. 在CMS Failure时,作为后备收集器。

Parallel Old收集器

老年代的,使用多线程和标记-整理算法的收集器。在jdk1.6c才开始提供。

如果注重吞吐量和CPU资源敏感的场景,可以优先考虑parallel scavenge + parallel old.

CMS收集器(Concurrent Mark Sweep)

并发收集器,以获取最短的回收停顿时间为目标;适用于重视响应速度的服务器。 有以下3个缺点:

  1. 当cpu资源敏感,当核数少时,由于占用部分资源,导致应用程序变慢;
  2. CMS无法处理浮动垃圾(CMS并发清理阶段,用户线程新产生的垃圾),可能出现Full GC;
  3. 标记-清除算法,会产生大量内存碎片。

G1收集器

从2004开始,经过10年时间才开发出商用版G1;其思想是,将整个java堆划分为多个大小相等的独立区域(Regin),新生代和老年代不再是物理隔离的,它们都是一部分Regin(不需要连续)的集合。G1跟踪每个regin的垃圾价值大小(回收时间越短,回收内存越大,则价值越大),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值较大的Regin。

理解GC日志

33.125: [GC [DefNew: 3324k->152k(3712k), 0.0025925 secs] 3324k->152k(11904k),0.0031680 secs]

100.667: [Full GC [Tenured:0k->210k(10240k),0.0149142 secs]4603k->210k(19456k),[perm : 2999k->2999k(21248k)],0.0150007 secs] [Times: user=0.01 sys=0.00,real =0.02 secs]

  • 33.125和100.667:表示jvm启动以来经过的秒数。
  • [GC和[Full GC:表示这次垃圾收集的停顿类型,如果有Full,则说明GC发生了Stop-The-World。
  • [DefNew和[Tenured:表示GC发生的区域,注意该名称和垃圾收集器密切相关。
  • 3324k->152k(3712k):GC前该区域已使用容量->GC后该内存区域已使用容量(该区域的总容量),在往后就是该区域GC占用的时间,单位秒。

垃圾收集器参数总结

参数描述
UseSerialGCclient模式下,打开此开关后,使用serial old的收集器组合进行内存回收
UseParNewGCParNew+Serial Old的收集器组合进行内存回收
UseConcMarkSweepGCParNew+CMS+Serial Old
UseParallelGCserver模式下,打开此开关后,使用Parallel Scavenge + Serial Old
UseParallelOldGCParallel Scavenge + Parallel Old
SurvivorRatio新生代中Eden区域与Survivor区域的容量比值,默认为8,Eden:Survivor=8:1
PretenureSizeThreshold直接进入老年代的对象大小阈值,当大于该值时,在老年代分配内存,设置成功不生效,原因垃圾收集器不支持
MaxTenuringThreshold晋升到老年代的年龄阈值
UseAdaptiveSizePolicy动态调整java堆中各个区域的大小,以及进入老年代的年龄
ParallelGCThreads设置并行GC时,进行内存回收的线程数
GCTimeRatioGC时间占总时间的比率,默认值99,即允许1%的GC时间,仅在Parallel Scavenge 收集器时生效
MaxGCPauseMillis设置最大的GC时间,仅在Parallel Scavenge收集器时有效
CMSInitatingOccupanyFraction设置老年代空间使用多少后触发垃圾收集,默认值68%,仅在CMS收集器时生效
UseCMSCompactAtFullCollection设置在一次垃圾收集后,进行碎片整理,仅在CMS收集器时有效
CMSFullGCsBeforeCompation设置进行若干次垃圾收集后,在进行一次内存碎片整理,仅在CMS收集器时有效

内存分配与回收策略

对象是在哪诞生的,相关验证代码请参看JVM书中。

对象优先在eden分配

一般情况,对象都在eden上诞生。

大对像直接进入老年代

可以配置jvm参数,超过指定大小的对象,直接在老年代诞生。注意,该参数和垃圾收集器有关。

长期存活的对象将进入老年代

默认超过15岁,会进入老年代。每次gc后年龄加1。

动态年龄判断

虚拟机并不是严格要求达到指定年龄(默认15),在进入老年代,如果在Survivor空间中相同年龄所有的对象大小的总和大于Survivor空间的一半,则年龄大于或者等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold。

空间分配担保

就是新生代内存不足时,直接分配在老年代。但是有担保风险,如果新生代存活内存大于老年代剩余内存,可能担保失败;此时HandlePromotionFailure设置允许担保失败的话,则先检查老年代最大可用连续空间是否大于历次晋升到老年代的平均大小,如果是则进行一次Minor GC;如果小于,或者设置不允许担保失败,此时也要进行一次Full GC。

第四章 虚拟机性能监控与故障处理工具

给系统看病的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。(周老师,观点毒辣)

JPS:虚拟机进程状况工具

使用频率最高的JDK命令行工具。

选项作用
-q只输出LVMID,省略主类的名称
-m输出启动进程时,传递给main函数的参数
-l输出主类的全名称,如果是jar包,则输出jar路径
-v输出启动jvm时显示指定的参数

jstat:虚拟机统计信息监视工具

用于监视虚拟机各种运行状态;类装载、内存、垃圾收集、JIT编译等。 jstat [option vmid [times count]]

选项作用
-gc监视java堆情况
-class监视类装载、卸载数量、总空间以及所耗费的时间

jinfo:java配置信息工具

jinfo的作用是实时查看和调整虚拟机的各项参数。

  1. 用jps查出进程ID
  2. jinfo -flag 参数名 进程ID 或者jdk1.6以上版本,打开查看参数默认值功能:-XX:+PrintFlagsFinal 一道面试题:如何查看jvm参数默认值?

jmap:java内存映像工具

jmap(memory map for java),生产dump文件;还可以查询finalize执行队列,java堆和永久代的详细信息。 jmap option vmid

选项作用
-dump生成堆转储快照
-heap显示java堆详细信息,如使用哪种回收器,参数配置,分代状况等
-histo显示堆中对象的统计信息,包括类、实例数量、合计容量

jhat:虚拟机堆转存储快照分析工具

一般没人用,都会使用更加专业的VisualVM、IBM HeapAnalyzer、Memory Analyzer。

jstack:堆栈跟踪工具

用于生成虚拟机当前时刻的线程快照,threaddump文件。用于定位线程长时间停顿的原因。

可视化工具

JConsole

VisualVM

Arthas(阿尔萨斯),强烈推荐,由阿里巴巴开源的java诊断工具。例如查个死锁问题,使用该工具更加容易定位问题。 Arthas 用户文档 — Arthas 3.5.4 文档 (aliyun.com)

第五章 调优案例分析与实战

第六章 类文件结构

第七章 虚拟机类加载机制

类被加载到虚拟机内存中的过程为:加载--->验证、准备、解析--->初始化--->使用--->卸载。

类什么时候开始加载?并且该5种情况称为:主动引用,其他情况则是被动引用

  1. 遇到new、getstatic、putstatic、或invokestatic这4条字节码指令;
  2. 使用反射时,如果类没有进行初始化的时候;
  3. 当初始化一个类时,发现其父类还没有初始化时,则需要先触发其父类的初始化;
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(main方法所在的类),虚拟机会先初始化该类;
  5. 当使用动态语言支持时,如果java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法的类没有进行初始化时,则需要先触发初始化。

类加载的过程详解

加载

完成以下3件事情:

  1. 获取二进制字节流(虚拟机规范并没有明确要从哪里读数据,给定了开放的舞台,因此衍生出许多技术);
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
  3. 在内存中生成代表这个类的Class对象,作为方法区这个类的各种数据的访问入口。

验证-(占比较大)

目的是确保Class文件包含的信息符合当前虚拟机,不会危害虚拟机自身安全。

为什么要验证? 因为字节码来源不一定java源文件编译而来的。

验证大致完成下面4个阶段的检验动作:

  1. 文件格式验证
  • 是否以魔数0xCAFEBABE开头;
  • 主、次版本号是否在当前虚拟机处理范围之内;
  • 常量池的常量是否有不被支持的类型;
  • 指向常量池的各种索引值中,是否指向不存在的常量或不符合类型的常量;
  • Class文件的各个部分及文件本身是否有被删除的或附加的其他信息。 还有好多其他的验证点,不在一一列举,该阶段主要目的是保证输入的字节流能正确的解析并存储于方法区之内;后面的验证全部基于方法区中存储结构进行,不在直接操作字节流。
  1. 元数据验证

对字节码秒数的信息进行语义分析,以保证符合java语言规范。

  • 如果这个类不是抽象类,是否实现了其父类或者接口之中要求实现的所有方法;
  • 类中的字段、方法、是否与父类产生矛盾;

3.字节码验证(最复杂)

通过数据流和控制流分析,确定语义是合法的、符合逻辑的。

4.符号引用验证

发生在将符号引用转化为直接引用的时候(解析阶段发生)。

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 符号引用中的类、字段、方法的访问性(权限private public)是否可被当前类访问。

准备

正式为类变量分配内存(方法区中),并设置类变量初始值。实例变量是在对象实例化时随着对象一起分配在java堆中。

public static int v = 1234;

在该阶段完毕时,v的值为0。

解析

符号引用

一组符号描述所引用的目标,只要求使用时无歧义的定位到目标即可;引用的目标不一定在内存中。

直接引用

理解为直接指向目标的指针、相对偏移量、句柄。

类或接口解析

字段解析

类方法解析

接口方法解析

这几个解析,还不是很理解,以后在深入学习。

初始化

在这一阶段,根据程序员在程序中的主观计划去初始化类变量和其他资源。在<clinit()>方法中进行。

类加载器

类与类加载器

对于人一个类,都需要由加载他的类加载器和这个类本身一起确立虚拟机中的唯一性。直白点说就是,必须同类加载器时比较才有意义。

双亲委派模型

从java开发者把类加载器分为:启动类加载器、扩展类加载器、应用程序加载器、自定义类加载器。

双亲委派工作过程:如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器,然后每一层级的类加载器都是如此,因此,所有的加载请求都会传到顶层的启动类加载器中,只有父类加载器反馈无法完成这个加载请求时,子加载器才会自己去加载。

这个机制对于保证java程序的稳定运行很重要。

第八章 虚拟机字节码执行引擎

第九章 类加载及执行子系统的案例与实战

第十章 早期(编译期)优化

从计算机诞生的第一天起,对效率的追求就是程序员天生的坚定信仰。

Javac编译期

语法、词法分析

语法分析是根据Token序列构造AST(Abstract Syntax Tree 抽象语法树),AST是一种用来描述程序代码语法结构的树状表示方式。注意,后续操作都基于AST。

填充符号表(没懂)

后期读懂,在填充剩余章节。

第十一章 晚期(运行期)优化

第十二章 java内存模型与线程

JMM

JMM模型.png 加入工作内存后,需要确保个线程间协作,对于共享数据一致性。

java线程实现

使用一对一的线程模型实现,一条java线程就映射到一条轻量级进程之中。

线程调度

协同式:线程的执行时间由线程自己控制,线程把自己的工作执行完毕以后才会通知系统切换到其他线程。

抢占式:每个线程由系统分配执行时间,线程的切换不由线程本身决定。

注意:java的优先级并不靠谱,原因就是java的优先级种类与操作系统做不到一一映射。

线程状态

线程状态.jpg

第十三章 线程安全与锁优化

线程安全:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象是线程安全的。

线程安全的实现方法

互斥同步

保证共享数据在同一时刻只被一个线程使用。

在虚拟机层面提供synchronized关键字实现锁同步(可重入锁),其底层通过monitorenter、monitorexit指令实现。

还有API层面的ReentrantLock,增加了一些高级特性,等待可中断、可实现公平锁(默认非公平)、锁绑定多个条件。

在JDK1.6之后synchronized被优化,性能大大的提升,因此推荐这种。

非阻塞同步

阻塞同步有严重的性能问题(线程切换涉及用户态与内核态的切换,耗时严重),典型例子CAS,比较并交换。

无同步方案

线程本地存储,每个线程独享一份数据,使用ThreadLocal工具类。

锁优化

自旋锁与自适应自旋

开启自旋锁: -XX:+UseSpinning(JDK1.6之后默认开启) 所谓自旋锁就是while(校验是否可以执行){执行对于逻辑代码};值得一提的是,如果锁占用时间很短,自旋等待的效果就非常好,因为不用切换状态,但是如果锁被占用时间较长,自旋的线程只会白白浪费CPU资源,因此,需要对业务做一个权衡。自旋次数默认10次(-XX:PreBlockSpin控制),超过10次没有成功获得锁,则使用传统挂起方式。

自适应自旋:就是虚拟机根据历史获取锁的情况智能的选择,是等待多久能获取锁,例如一般10毫秒,就等10ms;如果历史很少获取到锁,也就不在自旋了,白白浪费资源,虚拟机小伙会看历史数据了。

锁消除

虚拟机检测到一些代码有同步要求,但是不存在共享数据,因此锁消除。

锁粗化

编写代码原则上将同步代码块的范围限制得尽量小,减少影响范围。但是如果加锁操作是在循环体中,及时没有线程竞争,也会频繁的进行互斥同步操作,导致不必要的性能损耗,因此把锁的范围扩大,如此一来只加一次锁。

轻量级锁

轻量级锁是相对传统的锁(重量级锁),本意是在没有多线程竞争的前提下,减少传统重量级锁使用产生的性能损耗。

了解虚拟机对象头Mark Word。

偏向锁

轻量级锁是使用CAS保证线程安全,那偏向锁就是把整个同步去掉,连CAS操作都不做了。

锁升级:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级,一般认为只能升级不能降级。