“串一串” Java 虚拟机的知识点
说说 Java 的内存管理机制
和 C++ 相比,Java 的内存管理机制可谓是一大特色,程序员们不需要自己去写代码手动释放内存了,甚至你想自己干虚拟机都不给你干这个事情的机会(就是说,我们是没有办法自动触发 GC 的),虚拟机全权包办了 Java 的内存控制权力。这看起来挺美好的,不过也意味着,一旦虚拟机疏忽了(感觉不能赖虚拟机,毕竟虚拟机也不知道你能把程序写成那样啊……),发生了内存泄漏,问题都不好查,所以知道虚拟机到底是怎么管的内存就十分重要啦。
虚拟机对内存的管理,其实就是收拾那些存放我们不会再用的对象的内存,把它们清了拿来放新的对象。所以它首先需要研究下以下几个问题:
- 这堆报废了的对象到底被放哪了?(Java 堆和方法区)
- 这堆放报废对象的地方会不会内存泄漏?或者换一个洋气点的叫法,会不会 OOM?(每个区的 OOM)
- 对象是咋被放到这些地方的?(堆中对象的创建)
- 对象被安置好了之后虚拟机怎么再次找到它?(堆中对象的访问)
知道对象都放哪了,虚拟机就知道去哪里找报废的对象了,接下来就涉及到了 Java 的一大超级特色:垃圾收集(GC)了,垃圾收集,正如其名,就是把这些报废的对象给清了,腾出来地方放新对象,它主要关心以下几个事情:
-
哪些内存需要回收?
- 放对象的地方需要垃圾回收:Java 堆和方法区。
-
什么时候回收?(判断对象的生死)
-
如何回收?
-
GC 算法原理(垃圾收集算法)
-
-
-
GC 算法的真正实现:
- 7 个葫芦娃,哦不,垃圾收集器
- 新生代:Serial、ParNew、Parallel Scavenge
- 老年代:Serial Old、Parallel Old、CMS
- 全能:G1
- HotSpot 虚拟机如何高效实现 GC 算法
-
说完了对象是怎么被回收的,现在才算是把 Java 的内存管理机制需要用到的小零件给补全了。也就是说,Java 的内存管理流程应该是这样滴:
- 根据新对象是什么对象给对象找个地放
- 发现内存中没地放这个新对象了就进行 GC 清理出来点地方
- 真找不着地了就抛 OOM ……
虚拟机一般都用的是进化版的 GC 算法,也就是分代收集算法,也就是说,虚拟机 Java 堆中的内存是分为新生代和老年代的,那么给新对象找地方放的时候放哪呢?具体怎么放呢?放好了之后的对象会不会换个地呆呀?GC 什么时候进行?清理哪呢?……预知 Java 的内存管理机制的详情如何,请看:Java 内存分配策略。
到此为止,Java 的内存管理机制也就说的差不多了。现在,我们已经知道一个对象是如何在虚拟机的操控下,在内存中走一遭的了。可是首先,对象肯定是根据我们写的类创建的,那么我们写的类到底是如何变为内存中的对象的呢?而且,我们创建对象当然是为了执行它里面的方法呀,那么这个方法是怎么被执行的呢?想要回答这些问题,就需要我们研究一下 Java 虚拟机是如何执行我们的程序的了。
说说 Java 虚拟机程序执行
想要执行 Java 程序,必然要先将 Java 代码编译成字节码文件,也就是 Class 文件,这个编译的过程我们暂且不谈,主要说一下如何执行这个 Class 文件,所以首先我们要先来了解一下 Class 文件的组成结构。
在了解了组成结构之后,接下来需要考虑的事情是,我们该怎么把这个 .class 文件加载进内存,让它变成方法区(Java 8 后变为了 Metaspace 元空间)的一个 Class 对象呢?(类的加载)。
虚拟机的类加载机制说头可就多了,大家都喜欢揪着这问,其实主要就下面这 3 个过程:
-
类加载的时机:在程序第一次主动引用类的时候。
- 什么是主动引用和被动引用?
-
什么是显式加载和隐式加载?
-
类加载器
- 如何判断两个类 “相等”?
- 类加载器的分类?
- 什么双亲委派模型?
- 破坏双亲委派模型?
- 如何自定义类加载器?
- 需要保留双亲委派模型:
extends ClassLoader
,重写findClass()
- 破坏双亲委派模型:直接重写
loadClass()
- 需要保留双亲委派模型:
将类加载到内存之后,接下来就要考虑如何执行这个类中的方法了。我们知道 5 大内存区域中的 Java 虚拟机栈是服务与 Java 方法的内存模型,那么我们首先应该了解一下 虚拟机栈的栈帧到底是怎样的结构,虚拟机栈的栈帧结构包括如下几个部分:
了解了辅助方法执行的 Java 虚拟机栈的结构后,接下来就要考虑 Java 类中方法的调用了。就像将大象放进冰箱,方法的调用也不是上来就之间执行方法的,而是分为以下两个步骤:
为什么还要加一个方法调用的步骤呢?因为一切方法调用都是在 Class 文件中以常量池中的符号引用存储的,这就导致了不是我们想要执行哪个方法就能立刻执行的,因为我们首先需要根据这个符号引用(其实就一字符串)找到我们想要执行的方法,而这一过程就叫做方法调用。当找到这个方法之后,我们才会开始执行这个方法,也就是基于栈的解释执行。
想要调用一个方法,我们先来看一下虚拟机中有哪些指令可以进行方法调用:方法调用字节码指令。
这些字节码会触发不同的方法调用,总体来说,有以下几种:
-
分派调用
(没有在解析调用中将符号引用转化为直接引用的方法就只能靠分派调用了)
确定了要调用的方法具体是哪一个了之后,就可开始基于栈的解释执行了,这个时候,方法才真正的被执行。
此外,还需要了解一下 Java 的动态类型语言支持。
说说虚拟机性能监控及故障处理
常用的 JDK 命令行工具:JDK 命令行工具。
JVM 常见的参数设置可见:JVM 常见参数设置。
虚拟机调优案例分析可见:虚拟机调优案例分析。
说说 JIT 优化
JIT (Just In Time),也就是即时编译,首先我们需要知道 什么是 JIT?
然后,对于 HotSpot 虚拟机内的即时编译器运作过程,我们可以通过以下 5 个问题来研究它:
-
如何判断热点代码,触发编译?
-
HotSpot 采用的是基于计数器的热点探测方法,并且为了对两种热点代码进行探测,
每个方法有 2 个计数器:
- 方法调用计数器
- 回边计数器
此外,JIT 并不是简单的将热点代码编译成机器码就收工的,它还会对代码的执行进行优化,主要有以下几种经典的优化技术:
说说 Java 的内存模型 (JMM)
Java 的内存模型主要就是研究一个变量的值是怎么在主内存、线程的工作内存和 Java 线程(执行引擎)之间倒腾的。就是说虽然 Java 内存模型规定了所有变量都存储在主内存中,但是每个线程都有一个自己的工作内存,里面存着从主内存拷贝来的变量副本,Java 线程要对变量进行修改,都是先在自己的工作内存中进行,然后再把变化同步回主内存中去。
这样做是由于计算机的存储设备和处理器的运算速度有着几个数量级的差距,所以需要在主内存和 Java 线程间加入一个工作内存作为缓冲,但这也同时会导致主内存和工作内存间的缓存一致性问题,所以当两个工作内存中关于同一个变量的值发生冲突时,需要一定的访问规则来确定主内存以怎样的顺序同步这个变量,也就是说该听哪个工作内存的。而 Java 的内存模型的主要目标就是定义这个规则,即虚拟机如何将变量存储到内存或是从内存中取出的。
简单的来讲,就是掌握 Java 内存模型中的 8 个原子操作,并且知道 Java 内存间是如何通过这 8 个操作进行变量传递的。
其实 Java 的内存模型就是围绕着在并发的过程中如何处理 原子性、可见性、有序性 这 3 个特征建立的。同时 Java 除了可以依靠 volatile 和 synchronized 来保证有序性外,它自己本身还有一个 Happens-Before 原则,依靠这个原则,我们就可以判断并发环境下的两个操作是否可能存在冲突了。
Ch1-Java内存管理机制
00-Java 内存区域详解
JVM 运行时的数据区域
首先获取一个直观的认识:
总共也就这么 5 个区(直接内存不属于 JVM 运行时数据区的一部分),除了程序计数器其他的地方都有可能出现 OOM (OutOfMemoryError),其中像是程序计数器和两个栈(Java 虚拟机栈 & 本地方法栈)都是每个线程要有一个的,所以肯定是线程隔离的。而其他 2 个区就是线程共享的了,也就是说,如果有多个线程要同时访问这两个区的数据,是会出现线程安全问题的。接下来,我们将对这些区域进行详细的介绍。
程序计数器
- 当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来确定下一条要执行的字节码指令的位置
- 执行 Java 方法和 native 方法时的区别:
- 执行 Java 方法时:记录虚拟机正在执行的字节码指令地址;
- 执行 native 方法时:无定义;
- 是 5 个区域中唯一不会出现 OOM 的区域。
Java 虚拟机栈
- Java 方法执行的内存模型,每个方法执行的过程,就是它所对应的栈帧在虚拟机栈中入栈到出栈的过程;(每个方法被执行的时候, Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。)
- 服务于 Java 方法;
- 可能抛出的异常:
- OutOfMemoryError(在虚拟机栈可以动态扩展的情况下,扩展时无法申请到足够的内存);
- StackOverflowError(线程请求的栈深度 > 虚拟机所允许的深度);
- 虚拟机参数设置:
-Xss
.
本地方法栈
- 服务于 native 方法;
- 可能抛出的异常:与 Java 虚拟机栈一样。
Java 堆
- 唯一的目的:存放对象实例;
- 垃圾收集器管理的主要区域;
- 可以处于物理上不连续的内存空间中;
- 可能抛出的异常:
- OutOfMemoryError(堆中没有内存可以分配给新创建的实例,并且堆也无法再继续扩展了)。
- 虚拟机参数设置:
- 最大值:
-Xmx
- 最小值:
-Xms
- 两个参数设置成相同的值可避免堆自动扩展。
- 最大值:
方法区
- 存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;(JDK8之前方法区是使用永久代来实现的, 但是永久代有默认上限 容易出现内存溢出, 所以1.8后使用本地内存实现的元空间替代)
- 类信息:即 Class 类,如类名、访问修饰符、常量池、字段描述、方法描述等。
- 垃圾收集行为在此区域很少发生;
- 不过也不能不清理,对于经常动态生成大量 Class 的应用,如 Spring 等,需要特别注意类的回收状况。
- 运行时常量池也是方法区的一部分;
- Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池表,用于存放编译器生成的各种字面量(就是代码中定义的 static final 常量)和符号引用,这部分信息就存储在运行时常量池中。(这部分内容会在类加载后存放到方法区的运行时常量池中, 相对于class文件常量池来说, 运行时常量池具备动态性, 并不要求常量一定只有编译时才能产生, 运行期间也可以将新的变量放入池中, 如String类的intern()方法, 1.8之后放入堆中了)
- 可能抛出的异常:
- OutOfMemoryError(方法区无法满足内存分配需求时)。
直接内存
-
JDK 1.4 的 NIO 类可以使用 native 函数库直接分配堆外内存,这是一种基于通道与缓冲区的 I/O 方式,它在 Java 堆中存储一个 DirectByteBuffer 对象作为堆外内存的引用,这样就可以对堆外内存进行操作了。因为可以避免 Java 堆和 Native 堆之间来回复制数据,在一些场景可以带来显著的性能提高。
-
虚拟机参数设置:
-XX:MaxDirectMemorySize
- 默认等于 Java 堆最大值,即
-Xmx
指定的值。
- 默认等于 Java 堆最大值,即
-
直接内存不会受到java堆大小限制, 但是会受到本机总内存大小和cpu寻址空间的限制, 将直接内存放在这里讲解的原因是它也可能会出现 OutOfMemoryError;
- 服务器管理员在配置 JVM 参数时,会根据机器的实际内存设置
-Xmx
等信息,但经常会忽略直接内存(默认等于-Xmx
设置值),这可能会使得各个内存区域的总和大于物理内存限制,从而导致动态扩展时出现 OOM。
- 服务器管理员在配置 JVM 参数时,会根据机器的实际内存设置
HotSpot 虚拟机堆中的对象
这一小节将对 JVM 对 Java 堆中的对象的创建、布局和访问的全过程进行讲解。
对象的创建(遇到一条 new 指令时)
- 检查这个指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,先把这个类加载进内存;
- 类加载检查通过后,虚拟机将为新对象分配内存,此时已经可以确定存储这个对象所需的内存大小;
- 在堆中为新对象分配可用内存;
- 将分配到的内存初始化;
- 设置对象头中的数据;
- 此时,从虚拟机的角度看,对象已经创建好了,但从 Java 程序的角度看,对象创建才刚刚开始,构造函数还没有执行。
第 3 步,在堆中为新对象分配可用内存时,会涉及到以下两个问题:
如何在堆中为新对象划分可用的内存?
- 指针碰撞(内存分配规整)
- 用过的内存放一边,没用过的内存放一边,中间用一个指针分隔;
- 分配内存的过程就是将指针向没用过的内存那边移动所需的长度;
- 空闲列表(内存分配不规整)
- 维护一个列表,记录哪些内存块是可用的;
- 分配内存时,从列表上选取一块足够大的空间分给对象,并更新列表上的记录;
如何处理多线程创建对象时,划分内存的指针的同步问题?(对象创建在虚拟机中时非常频繁的行为, 即使仅仅修改一个指针所指向的位置, 在并发情况下也不是线程安全的, 可能出现正在给对象A分配内存, 指针还没来得及修改, 对象B又同时使用了原来的指针来分配内存的情况)
- 对分配内存空间的动作进行同步处理(CAS配上失败重试的方式保证更新操作的原子性);
- 把内存分配动作按照线程划分在不同的空间之中进行;
- 每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB);
- 哪个线程要分配内存就在哪个线程的 TLAB 上分配,TLAB 用完需要分配新的 TLAB 时,才需要同步锁定;
- 通过
-XX:+/-UseTLAB
参数设定是否使用 TLAB。 - 注: TLAB是虚拟机在堆内存的eden划分出来的一块专用空间,是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。但是只是在“分配”这个动作上是线程独占的,至于在读取、垃圾回收等动作上都是线程共享的。而且在使用上也没有什么区别。
对象的内存布局
- 对象头:
- 第一部分:存储对象自身运行时的数据,HashCode、GC分代年龄等(Mark Word);
- 第二部分:类型指针,指向它的类元数据的指针,虚拟机通过这个指针来判断这个对象是哪个类的实例(HotSpot 采用的是直接指针的方式访问对象的);
- 如果是个数组对象,对象头中还有一块用于记录数组长度的数据。
- 实例数据:
- 默认分配顺序:longs/doubles、ints、shorts/chars、bytes/booleans、oops (Ordinary Object Pointers),相同宽度的字段会被分配在一起,除了 oops,其他的长度由长到短;
- 默认分配顺序下,父类字段会被分配在子类字段前面。
注:HotSpot VM要求对象的起始地址必须是8字节的整数倍,所以不够要补齐。
对象的访问
Java 程序需要通过虚拟机栈上的 reference 数据来操作堆上的具体对象,reference 数据是一个指向对象的引用,不过如何通过这个引用定位到具体的对象,目前主要有以下两种访问方式:句柄访问和直接指针访问。
句柄访问
句柄访问会在 Java 堆中划分一块内存作为句柄池,每一个句柄存放着到对象实例数据和对象类型数据的指针。
优势:对象移动的时候(这在垃圾回收时十分常见)只需改变句柄池中对象实例数据的指针,不需要修改reference本身。
直接指针访问
直接指针访问方式在 Java 堆对象的实例数据中存放了一个指向对象类型数据的指针,在 HotSpot 中,这个指针会被存放在对象头中。
优势:减少了一次指针定位对象实例数据的开销,速度更快。
01-OOM 异常 (OutOfMemoryError)
Java 堆溢出
- 出现标志:
java.lang.OutOfMemoryError: Java heap space
- 解决方法:
- 先通过内存映像分析工具分析 Dump 出来的堆转储快照,确认内存中的对象是否是必要的,即分清楚是出现了内存泄漏还是内存溢出;
- 如果是内存泄漏,通过工具查看泄漏对象到 GC Root 的引用链,定位出泄漏的位置;
- 如果不存在泄漏,检查虚拟机堆参数(-Xmx 和 -Xms)是否可以调大,检查代码中是否有哪些对象的生命周期过长,尝试减少程序运行期的内存消耗。
- 虚拟机参数:
-XX:HeapDumpOnOutOfMemoryError
:让虚拟机在出现内存泄漏异常时 Dump 出当前的内存堆转储快照用于事后分析。
Java 虚拟机栈和本地方法栈溢出
- 单线程下,栈帧过大、虚拟机容量过小都不会导致 OutOfMemoryError,只会导致 StackOverflowError(栈会比内存先爆掉),一般多线程才会出现 OutOfMemoryError,因为线程本身要占用内存;
- 如果是多线程导致的 OutOfMemoryError,在不能减少线程数或更换 64 位虚拟机的情况,只能通过减少最大堆和减少栈容量来换取更多的线程;
- 这个调节思路和 Java 堆出现 OOM 正好相反,Java 堆出现 OOM 要调大堆内存的设置值,而栈出现 OOM 反而要调小。
方法区和运行时常量池溢出
- 测试思路:产生大量的类去填满方法区,直到溢出;
- 在经常动态生成大量 Class 的应用中,如 Spring 框架(使用 CGLib 字节码技术),方法区溢出是一种常见的内存溢出,要特别注意类的回收状况。
直接内存溢出
- 出现特征:Heap Dump 文件中看不见明显异常,程序中直接或间接用了 NIO;
- 虚拟机参数:
-XX:MaxDirectMemorySize
,如果不指定,则和-Xmx
一样。
02-垃圾收集 (GC)
垃圾收集(Garbage Collection,GC),它的任务是解决以下 3 件问题:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
其中第一个问题很好回答,在 Java 中,GC 主要发生在 Java 堆和方法区中,对于后两个问题,我们将在之后的内容中进行讨论,并介绍 HotSpot 的 7 个垃圾收集器。
判断对象的生死
什么时候回收对象?当然是这个对象再也不会被用到的时候回收。所以要想解决 “什么时候回收?” 这个问题,我们要先能判断一个对象什么时候什么时候真正的 “死” 掉了,判断对象是否可用主要有以下两种方法。
判断对象是否可用的算法
引用计数算法
- 算法描述:
- 给对象添加一个引用计数器;
- 每有一个地方引用它,计数器加 1;
- 引用失效时,计数器减 1;
- 计数器值为 0 的对象不再可用。
- 缺点:
- 很难解决循环引用的问题。即
objA.instance = objB; objB.instance = objA;
,objA 和 objB 都不会再被访问后,它们仍然相互引用着对方,所以它们的引用计数器不为 0,将永远不能被判为不可用。
- 很难解决循环引用的问题。即
可达性分析算法(主流)
- 算法描述:
- 从 "GC Root" 对象作为起点开始向下搜索,走过的路径称为引用链(Reference Chain);
- 从 "GC Root" 开始,不可达的对象被判为不可用。
- Java 中可作为 “GC Root” 的对象:
- 栈中(本地变量表中的reference)
- 虚拟机栈中,栈帧中的本地变量表引用的对象;
- 本地方法栈中,JNI 引用的对象(native方法);
- 方法区中
- 类的静态属性引用的对象;
- 常量引用的对象; (注: 除以上两种情况外单纯堆中实例数据所引用的对象不能作为GC Root, 但如果其在引用链中, 可认为可达) 即便如此,一个对象也不是一旦被判为不可达,就立即死去的,宣告一个的死亡需要经过两次标记过程。
- 栈中(本地变量表中的reference)
四种引用类型
JDK 1.2 后,Java 中才有了后 3 种引用的实现。
- 强引用: 像
Object obj = new Object()
这种,只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。 - 软引用: 用来引用还存在但非必须的对象。对于软引用对象,在 OOM 前,虚拟机会把这些对象列入回收范围中进行第二次回收,如果这次回收后,内存还是不够用,就 OOM。实现类:
SoftReference
。 - 弱引用: 被弱引用引用的对象只能生存到下一次垃圾收集前,一旦发生垃圾收集,被弱引用所引用的对象就会被清掉。实现类:
WeakReference
。 - 虚引用: 幽灵引用,对对象没有半毛钱影响,甚至不能用来取得一个对象的实例。它唯一的用途就是:当被一个虚引用引用的对象被回收时,系统会收到这个对象被回收了的通知。实现类:
PhantomReference
。
宣告对象死亡的两次标记过程
-
当发现对象不可达后,该对象被第一次标记,并进行是否有必要执行
finalize()
方法的判断;
- 不需要执行:对象没有覆盖
finalize()
方法,或者finalize()
方法已被执行过(finalize()
只被执行一次);
- 不需要执行:对象没有覆盖
-
需要执行:将该对象放置在一个队列中,稍后由一个虚拟机自动创建的低优先级线程执行。
-
finalize()
方法是对象逃脱死亡的最后一次机会,不过虚拟机不保证等待finalize()
方法执行结束,也就是说,虚拟机只触发finalize()
方法的执行,如果这个方法要执行超久,那么虚拟机并不等待它执行结束,所以最好不要用这个方法。 -
finalize()
方法能做的,try-finally 都能做,所以忘了这个方法吧!
方法区的回收
永久代的 GC 主要回收:废弃常量 和 无用的类。
- 废弃常量:例如一个字符串 "abc",当没有任何引用指向 "abc" 时,它就是废弃常量了。
- 无用的类:同时满足以下 3 个条件的类。
- 该类的所有实例已被回收,Java 堆中不存在该类的任何实例;
- 加载该类的 Classloader 已被回收;
- 该类的 Class 对象没有被任何地方引用,即无法在任何地方通过反射访问该类的方法。
垃圾收集算法
基础:标记 - 清除算法
- 算法描述:
- 先标记出所有需要回收的对象(图中深色区域);
- 标记完后,统一回收所有被标记对象(留下狗啃似的可用内存区域……)。
- 不足:
- 效率问题:标记和清理两个过程的效率都不高。
- 空间碎片问题:标记清除后会产生大量不连续的内存碎片,导致以后为较大的对象分配内存时找不到足够的连续内存,会提前触发另一次 GC。
解决效率问题:复制算法
- 算法描述:
- 将可用内存分为大小相等的两块,每次只使用其中一块;
- 当一块内存用完时,将这块内存上还存活的对象复制到另一块内存上去,将这一块内存全部清理掉。
- 不足: 可用内存缩小为原来的一半,适合GC过后只有少量对象存活的新生代。
- 节省内存的方法:
- 新生代中的对象 98% 都是朝生夕死的,所以不需要按照 1:1 的比例对内存进行划分;
- 把内存划分为:
- 1 块比较大的 Eden 区;
- 2 块较小的 Survivor 区;
- 每次使用 Eden 区和 1 块 Survivor 区;
- 回收时,将以上 2 部分区域中的存活对象复制到另一块 Survivor 区中,然后将以上两部分区域清空;
- JVM 参数设置:
-XX:SurvivorRatio=8
表示Eden 区大小 / 1 块 Survivor 区大小 = 8
。
解决空间碎片问题:标记 - 整理算法
- 算法描述:
- 标记方法与 “标记 - 清除算法” 一样;
- 标记完后,将所有存活对象向一端移动,然后直接清理掉边界以外的内存。
- 不足: 存在效率问题,适合老年代。
进化:分代收集算法
- 新生代: GC 过后只有少量对象存活 —— 复制算法
- 老年代: GC 过后对象存活率高 —— 标记 - 整理算法
HotSpot 中 GC 算法的实现
通过前两小节对于判断对象生死和垃圾收集算法的介绍,我们已经对虚拟机是进行 GC 的流程有了一个大致的了解。但是,在 HotSpot 虚拟机中,高效的实现这些算法也是一个需要考虑的问题。所以,接下来,我们将研究一下 HotSpot 虚拟机到底是如何高效的实现这些算法的,以及在实现中有哪些需要注意的问题。
通过之前的分析,GC 算法的实现流程简单的来说分为以下两步:
- 找到死掉的对象;
- 把它清了。
想要找到死掉的对象,我们就要进行可达性分析,也就是从 GC Root 找到引用链的这个操作。
也就是说,进行可达性分析的第一步,就是要枚举 GC Roots,这就需要虚拟机知道哪些地方存放着对象应用。如果每一次枚举 GC Roots 都需要把整个栈上位置都遍历一遍,那可就费时间了,毕竟并不是所有位置都存放在引用呀。所以为了提高 GC 的效率,HotSpot 使用了一种 OopMap 的数据结构,OopMap 记录了栈上本地变量到堆上对象的引用关系,也就是说,GC 的时候就不用遍历整个栈只遍历每个栈的 OopMap 就行了。
在 OopMap 的帮助下,HotSpot 可以快速准确的完成 GC 枚举了,不过,OopMap 也不是万年不变的,它也是需要被更新的,当内存中的对象间的引用关系发生变化时,就需要改变 OopMap 中的相应内容。可是能导致引用关系发生变化的指令非常之多,如果我们执行完一条指令就改下 OopMap,这 GC 成本实在太高了。
因此,HotSpot 采用了一种在 “安全点” 更新 OopMap 的方法,安全点的选取既不能让 GC 等待的时间过长,也不能过于频繁增加运行负担,也就是说,我们既要让程序运行一段时间,又不能让这个时间太长。我们知道,JVM 中每条指令执行的是很快的,所以一个超级长的指令流也可能很快就执行完了,所以 真正会出现 “长时间执行” 的一般是指令的复用,例如:方法调用、循环跳转、异常跳转等,虚拟机一般会将这些地方设置为安全点更新 OopMap 并判断是否需要进行 GC 操作。
此外,在进行枚举根节点的这个操作时,为了保证准确性,我们需要在一段时间内 “冻结” 整个应用,即 Stop The World(传说中的 GC 停顿),因为如果在我们分析可达性的过程中,对象的引用关系还在变来变去,那是不可能得到正确的分析结果的。即便是在号称几乎不会发生停顿的 CMS 垃圾收集器中,枚举根节点时也是必须要停顿的。这里就涉及到了一个问题:
我们让所有线程跑到最近的安全点再停顿下来进行 GC 操作呢?
主要有以下两种方式:
-
抢先式中断:
- 先中断所有线程;
- 发现有线程没中断在安全点,恢复它,让它跑到安全点。
-
主动式中断:(主要使用)
- 设置一个中断标记;
-
每个线程到达安全点时,检查这个中断标记,选择是否中断自己。
除此安全点之外,还有一个叫做 “安全区域” 的东西,一个一直在执行的线程可以自己 “走” 到安全点去,可是一个处于 Sleep 或者 Blocked 状态的线程是没办法自己到达安全点中断自己的,我们总不能让 GC 操作一直等着这些个 ”不执行“ 的线程重新被分配资源吧。对于这种情况,我们要依靠安全区域来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化,因此在这个区域中的任意位置开始 GC 都是安全的。
当线程执行到安全区域时,它会把自己标识为 Safe Region,这样 JVM 发起 GC 时是不会理会这个线程的。当这个线程要离开安全区域时,它会检查系统是否在 GC 中,如果不在,它就继续执行,如果在,它就等 GC 结束再继续执行。
本小节我们主要讲述 HotSpot 虚拟机是如何发起内存回收的,也就是如何找到死掉的对象,至于如何清掉这些个对象,HotSpot 将其交给了一堆叫做 ”GC 收集器“ 的东西,这东西又有好多种,不同的 GC 收集器的处理方式不同,适用的场景也不同,我们将在下一小节进行详细讲述。
7 个垃圾收集器
垃圾收集器就是内存回收操作的具体实现,HotSpot 里足足有 7 种,为啥要弄这么多,因为它们各有各的适用场景。有的属于新生代收集器,有的属于老年代收集器,所以一般是搭配使用的(除了万能的 G1)。关于它们的简单介绍以及分类请见下图。
Serial / ParNew 搭配 Serial Old 收集器
Serial 收集器是虚拟机在 Client 模式下的默认新生代收集器,它的优势是简单高效,在单 CPU 模式下很牛。
ParNew 收集器就是 Serial 收集器的多线程版本,虽然除此之外没什么创新之处,但它却是许多运行在 Server 模式下的虚拟机中的首选新生代收集器,因为除了 Serial 收集器外,只有它能和 CMS 收集器搭配使用。
Parallel 搭配 Parallel Scavenge 收集器
首先,这俩货肯定是要搭配使用的,不仅仅如此,它俩还贼特别,它们的关注点与其他收集器不同,其他收集器关注于尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目的是达到一个可控的吞吐量。
吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )
因此,Parallel Scavenge 收集器不管是新生代还是老年代都是多个线程同时进行垃圾收集,十分适合于应用在注重吞吐量以及 CPU 资源敏感的场合。
可调节的虚拟机参数:
-XX:MaxGCPauseMillis
:最大 GC 停顿的秒数;-XX:GCTimeRatio
:吞吐量大小,一个 0 ~ 100 的数,最大 GC 时间占总时间的比率 = 1 / (GCTimeRatio + 1)
;-XX:+UseAdaptiveSizePolicy
:一个开关参数,打开后就无需手工指定-Xmn
,-XX:SurvivorRatio
等参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,自行调整。
CMS 收集器
参数设置:
-XX:+UseCMSCompactAtFullCollection
:在 CMS 要进行 Full GC 时进行内存碎片整理(默认开启)-XX:CMSFullGCsBeforeCompaction
:在多少次 Full GC 后进行一次空间整理(默认是 0,即每一次 Full GC 后都进行一次空间整理)
关于 CMS 使用 标记 - 清除 算法的一点思考:
之前对于 CMS 为什么要采用 标记 - 清除 算法十分的不理解,既然已经有了看起来更高级的 标记 - 整理 算法,那 CMS 为什么不用呢?最近想了想,感觉可能是这个原因,不过也不是很确定,只是个人的一种猜测。
标记 - 整理 会将所有存活对象向一端移动,然后直接清理掉边界以外的内存。这就意味着需要一个指针来维护这个分隔存活对象和无用空间的点,而我们知道 CMS 是并发清理的,虽然我们启动了多个线程进行垃圾回收,不过如果使用 标记 - 整理 算法,为了保证线程安全,在整理时要对那个分隔指针加锁,保证同一时刻只有一个线程能修改它,加锁的这一过程相当于将并行的清理过程变成了串行的,也就失去了并行清理的意义了。
所以,CMS 采用了 标记 - 清除 算法。
G1 收集器
GC 日志解读
垃圾收集器参数总结
03-Java 内存分配策略
新生代和老年代的 GC 操作
- 新生代 GC 操作:Minor GC
- 发生的非常频繁,速度较块。
- 老年代 GC 操作:Full GC / Major GC
- 经常伴随着至少一次的 Minor GC;
- 速度一般比 Minor GC 慢上 10 倍以上。
优先在 Eden 区分配
- Eden 空间不够将会触发一次 Minor GC;
- 虚拟机参数:
-Xmx
:Java 堆的最大值;-Xms
:Java 堆的最小值;-Xmn
:新生代大小;-XX:SurvivorRatio=8
:Eden 区 / Survivor 区 = 8 : 1
大对象直接进入老年代
-
大对象定义: 需要大量连续内存空间的 Java 对象。例如那种很长的字符串或者数组。
-
设置对象直接进入老年代大小限制:
- -XX:PretenureSizeThreshold:单位是字节;
- 只对 Serial 和 ParNew 两款收集器有效。
- -XX:PretenureSizeThreshold:单位是字节;
-
目的: 因为新生代采用的是复制算法收集垃圾,大对象直接进入老年代可以避免在 Eden 区和 Survivor 区发生大量的内存复制。
长期存活的对象将进入老年代
- 固定对象年龄判定: 虚拟机给每个对象定义一个年龄计数器,对象每在 Survivor 中熬过一次 Minor GC,年龄 +1,达到
-XX:MaxTenuringThreshold
设定值后,会被晋升到老年代,-XX:MaxTenuringThreshold
默认为 15; - 动态对象年龄判定: Survivor 中有相同年龄的对象的空间总和大于 Survivor 空间的一半,那么,年龄大于或等于该年龄的对象直接晋升到老年代。
空间分配担保
我们知道,新生代采用的是复制算法清理内存,每一次 Minor GC,虚拟机会将 Eden 区和其中一块 Survivor 区的存活对象复制到另一块 Survivor 区,但 当出现大量对象在一次 Minor GC 后仍然存活的情况时,Survivor 区可能容纳不下这么多对象,此时,就需要老年代进行分配担保,即将 Survivor 无法容纳的对象直接进入老年代。
这么做有一个前提,就是老年代得装得下这么多对象。可是在一次 GC 操作前,虚拟机并不知道到底会有多少对象存活,所以空间分配担保有这样一个判断流程:
- 发生 Minor GC 前,虚拟机先检查老年代的最大可用连续空间是否大于新生代所有对象的总空间;
- 如果大于,Minor GC 一定是安全的;
- 如果小于,虚拟机会查看 HandlePromotionFailure 参数,看看是否允许担保失败;
- 允许失败:尝试着进行一次 Minor GC;
- 不允许失败:进行一次 Full GC;
- 不过 JDK 6 Update 24 后,HandlePromotionFailure 参数就没有用了,规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 Full GC。
Metaspace 元空间与 PermGen 永久代
Java 8 彻底将永久代 (PermGen) 移除出了 HotSpot JVM,将其原有的数据迁移至 Java Heap 或 Metaspace。
移除 PermGen 的原因:
- PermGen 内存经常会溢出,引发恼人的 java.lang.OutOfMemoryError: PermGen,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM;
- 移除 PermGen 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。
移除 PermGen 后,方法区和字符串常量的位置:
- 方法区:移至 Metaspace;
- 字符串常量:移至 Java Heap。
Metaspace 的位置: 本地堆内存(native heap)。
Metaspace 的优点: 永久代 OOM 问题将不复存在,因为默认的类的元数据分配只受本地内存大小的限制,也就是说本地内存剩余多少,理论上 Metaspace 就可以有多大;
JVM参数:
-XX:MetaspaceSize
:分配给类元数据空间(以字节计)的初始大小,为估计值。MetaspaceSize的值设置的过大会延长垃圾回收时间。垃圾回收过后,引起下一次垃圾回收的类元数据空间的大小可能会变大。-XX:MaxMetaspaceSize
:分配给类元数据空间的最大值,超过此值就会触发Full GC,取决于系统内存的大小。JVM会动态地改变此值。-XX:MinMetaspaceFreeRatio
:一次GC以后,为了避免增加元数据空间的大小,空闲的类元数据的容量的最小比例,不够就会导致垃圾回收。-XX:MaxMetaspaceFreeRatio
:一次GC以后,为了避免增加元数据空间的大小,空闲的类元数据的容量的最大比例,不够就会导致垃圾回收。
Ch2- Java虚拟机程序执行
00-Class 文件的组成结构
Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件中,中间没有任何分隔符。Java 虚拟机规范规定 Class 文件采用一种类似 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表,我们之后也主要对这两种类型的数据类型进行解析。
- 无符号数: 无符号数属于基本数据类型,以 u1、u2、u4、u8 分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,可以用它来描述数字、索引引用、数量值或 utf-8 编码的字符串值。
- 表: 表是由多个无符号数或其他表为数据项构成的复合数据类型,名称上都以
_info
结尾。
Class 文件的头 8 个字节
Class 文件的头 8 个字节是魔数和版本号,其中头 4 个字节是魔数,也就是 0xCAFEBABE
,它可以用来确定这个文件是否为一个能被虚拟机接受的 Class 文件(比通过扩展名来识别文件类型要安全,毕竟扩展名是可以随便修改的)。
后 4 个字节则是当前 Class 文件的版本号,其中第 5、6 个字节是次版本号,第 7、8 个字节是主版本号。
常量池
从第 9 个字节开始,就是常量池的入口,常量池是 Class 文件中:
- 与其他项目关联最多的的数据类型;
- 占用 Class 文件空间最大的数据项目;
- Class 文件中第一个出现的表类型数据项目。
常量池的开始的两个字节,也就是第 9、10 个字节,放置一个 u2 类型的数据,标识常量池中常量的数量 cpc (constant_pool_count),这个计数值有一个十分特殊的地方,就是它是从 1 开始而不是从 0 开始的,也就是说如果 cpc = 22,那么代表常量池中有 21 项常量,索引值为 1 ~ 21,第 0 项常量被空出来,为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”时,将让这个索引值指向 0 即可。
常量池中记录的是代码出现过的所有 token(类名,成员变量名等,也是我们接下来要修改的地方)以及符号引用(方法引用,成员变量引用等),主要包括以下两大类常量:
- 字面量:接近于 Java 语言层面的常量概念,包括
- 文本字符串
- 声明为 final 的常量值
- 符号引用:以一组符号来描述所引用的目标,包括
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
常量池中的每一项常量都通过一个表来存储。目前一共有 14 种常量,不过麻烦的地方就在于,这 14 种常量类型每一种都有自己的结构,我们在这里只详细介绍两种:CONSTANT_Class_info 和 CONSTANT_Utf8_info。
CONSTANT_Class_info 的存储结构为:
... [ tag=7 ] [ name_index ] ...
... [ 1位 ] [ 2位 ] ...
其中,tag 是标志位,用来区分常量类型的,tag = 7 就表示接下来的这个表是一个 CONSTANT_Class_info,name_index 是一个索引值,指向常量池中的一个 CONSTANT_Utf8_info 类型的常量所在的索引值,CONSTANT_Utf8_info 类型常量一般被用来描述类的全限定名、方法名和字段名。它的存储结构如下:
... [ tag=1 ] [ 当前常量的长度 len ] [ 常量的符号引用的字符串值 ] ...
... [ 1位 ] [ 2位 ] [ len位 ] ...
01-虚拟机的类加载机制
类加载的时机
JVM 会在程序第一次主动引用类的时候,加载该类,被动引用时并不会引发类加载的操作。也就是说,JVM 并不是在一开始就把一个程序就所有的类都加载到内存中,而是到不得不用的时候才把它加载进来,而且只加载一次。那么什么是主动引用,什么是被动引用呢?
- 主动引用
- 遇到 new、getstatic、putstatic、invokestatic 字节码指令,例如:
- 使用 new 实例化对象;
- 读取或设置一个类的 static 字段(被 final 修饰的除外);
- 调用类的静态方法。
- 对类进行反射调用;
- 初始化一个类时,其父类还没初始化(需先初始化父类);
- 这点类与接口具有不同的表现,接口初始化时,不要求其父接口完成初始化,只有真正使用父接口时才初始化,如引用父接口中定义的常量。
- 虚拟机启动,先初始化包含 main() 函数的主类;
- JDK 1.7 动态语言支持:一个 java.lang.invoke.MethodHandle 的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic。
- 遇到 new、getstatic、putstatic、invokestatic 字节码指令,例如:
- 被动引用
- 通过子类引用父类静态字段,不会导致子类初始化;
Array[] arr = new Array[10];
不会触发 Array 类初始化;static final VAR
在编译阶段会存入调用类的常量池,通过ClassName.VAR
引用不会触发 ClassName 初始化。
也就是说,只有发生主动引用所列出的 5 种情况,一个类才会被加载到内存中,也就是说类的加载是 lazy-load 的,不到必要时刻是不会提前加载的,毕竟如果将程序运行中永远用不到的类加载进内存,会占用方法区中的内存,浪费系统资源。
注: 数组和Array类的区别https://www.cnblogs.com/wuchangming/archive/2013/03/28/2986184.html
类的显式加载和隐式加载
- 显示加载:
- 调用
ClassLoader#loadClass(className)
或Class.forName(className)
。 - 两种显示加载 .class 文件的区别:
Class.forName(className)
加载 class 的同时会初始化静态域,ClassLoader#loadClass(className)
不会初始化静态域;- Class.forName 借助当前调用者的 class 的 ClassLoader 完成 class 的加载。
- 调用
- 隐式加载:
- new 类对象;
- 使用类的静态域;
- 创建子类对象;
- 使用子类的静态域;
- 其他的隐式加载,在 JVM 启动时:
- BootStrapLoader 会加载一些 JVM 自身运行所需的 Class;
- ExtClassLoader 会加载指定目录下一些特殊的 Class;
- AppClassLoader 会加载 classpath 路径下的 Class,以及 main 函数所在的类的 Class 文件。
类加载的过程
类的生命周期
加载 --> 验证 --> 准备 --> 解析 --> 初始化 --> 使用 --> 卸载
|<------- 连接 ------->|
|<------------- 类加载 ---------------->|
类的生命周期一共有 7 个阶段,其中前五个阶段较为重要,统称为类加载,第 2 ~ 4 阶段统称为连接,加载和连接中的三个过程开始的顺序是固定的,但是执行过程中是可以交叉执行的。接下来,我们将对类加载的 5 个阶段进行一一讲解。
加载
加载的 3 个阶段
- 通过类的全限定名获取二进制字节流(将 .class 文件读进内存);
- 将字节流的静态存储结构转化为运行时的数据结构;
- 在内存中生成该类的 Class 对象;
- HotSpot 虚拟机把这个对象放在方法区,非 Java 堆。
分类
- 非数组类
- 系统提供的引导类加载器
- 用户自定义的类加载器
- 数组类
- 不通过类加载器,由 Java 虚拟机直接创建
- 创建动作由 newarray 指令触发,new 实际上触发了
[L全类名
对象的初始化 - 规则
- 数组元素是引用类型
- 加载:递归加载其组件
- 可见性:与引用类型一致
- 数组元素是非引用类型
- 加载:与引导类加载器关联
- 可见性:public
- 数组元素是引用类型
验证
- 目的: 确保 .class 文件中的字节流信息符合虚拟机的要求。
- 4 个验证过程:
- 文件格式验证:是否符合 Class 文件格式规范,验证文件开头 4 个字节是不是 “魔数”
0xCAFEBABE
- 元数据验证:保证字节码描述信息符号 Java 规范(语义分析)
- 字节码验证:程序语义、逻辑是否正确(通过数据流、控制流分析)
- 符号引用验证:对类自身以外的信息(常量池中的符号引用)进行匹配性校验, 判断该类是否缺少或者被禁止访问它依赖的某些外部类、 方法、 字段等资源, 目的是确保解析行为能正常执行.
- 文件格式验证:是否符合 Class 文件格式规范,验证文件开头 4 个字节是不是 “魔数”
- 这个操作虽然重要,但不是必要的,可以通过
-Xverify:none
关掉。
准备
-
描述: 为 static 变量在方法区分配内存。
-
static 变量准备后的初始值:
-
public static int value = 123;
- 准备后为 0,value 的赋值指令 putstatic 会被放在
<clinit>()
方法中,<clinit>()
方法会在初始化时执行,也就是说,value 变量只有在初始化后才等于 123。
- 准备后为 0,value 的赋值指令 putstatic 会被放在
-
public static final int value = 123;
- 准备后为 123,因为被
static final
赋值之后 value 就不能再修改了,所以在这里进行了赋值之后,之后不可能再出现赋值操作,所以可以直接在准备阶段就把 value 的值初始化好。
- 准备后为 123,因为被
-
解析
-
描述:
将常量池中的 “符号引用” 替换为 “直接引用”。
- 在此之前,常量池中的引用是不一定存在的,解析过之后,可以保证常量池中的引用在内存中一定存在。
-
什么是 “符号引用” 和 “直接引用” ?
- 符号引用:以一组符号描述所引用的对象(如对象的全类名),引用的目标不一定存在于内存中。
- 直接引用:直接指向被引用目标在内存中的位置的指针等,也就是说,引用的目标一定存在于内存中。
- 解析动作主要针对类或接口、 字段、 类方法、 接口方法、 方法类型、 方法句柄和调用点限定符这7
类符号引用进行
初始化
- 描述: 执行类构造器
<clinit>()
方法的过程。 <clinit>()
方法- 包含的内容:
- 所有 static 的赋值操作;
- static 块中的语句;
<clinit>()
方法中的语句顺序:- 基本按照语句在源文件中出现的顺序排列;
- 静态语句块只能访问定义在它前面的变量,定义在它后面的变量,可以赋值,但不能访问。
- 与
<init>()
方法的不同:- 不需要显示调用父类的
<clinit>()
方法; - 虚拟机保证在子类的
<clinit>()
方法执行前,父类的<clinit>()
方法一定执行完毕。- 也就是说,父类的 static 块和 static 字段的赋值操作是要先于子类的。
- 不需要显示调用父类的
- 接口与类的不同:
- 执行子接口的
<clinit>()
方法前不需要先执行父接口的<clinit>()
方法(除非用到了父接口中定义的 public static final 变量);
- 执行子接口的
- 执行过程中加锁:
- 同一时刻只能有一个线程在执行
<clinit>()
方法,因为虚拟机要保证在同一个类加载器下,一个类只被加载一次。
- 同一时刻只能有一个线程在执行
- 非必要性:
- 一个类如果没有任何 static 的内容就不需要执行
<clinit>()
方法。
- 一个类如果没有任何 static 的内容就不需要执行
- 包含的内容:
注:初始化时,才真正开始执行类中定义的 Java 代码。
类加载器
如何判断两个类 “相等”
- “相等” 的要求
- 同一个 .class 文件
- 被同一个虚拟机加载
- 被同一个类加载器加载
- 判断 “相等” 的方法
instanceof
关键字- Class 对象中的方法:
equals()
isInstance()
isAssignableFrom()
类加载器的分类
- 启动类加载器(Bootstrap)
- <JAVA_HOME>/lib
- -Xbootclasspath 参数指定的路径
- 扩展类加载器(Extension)
- <JAVA_HOME>/lib/ext
- java.ext.dirs 系统变量指定的路径
- 应用程序类加载器(Application)
- -classpath 参数
双亲委派模型
- 工作过程
- 当前类加载器收到类加载的请求后,先不自己尝试加载类,而是先将请求委派给父类加载器
- 因此,所有的类加载请求,都会先被传送到启动类加载器
- 只有当父类加载器加载失败时,当前类加载器才会尝试自己去自己负责的区域加载
- 当前类加载器收到类加载的请求后,先不自己尝试加载类,而是先将请求委派给父类加载器
- 实现
- 检查该类是否已经被加载
- 将类加载请求委派给父类
- 如果父类加载器为 null,默认使用启动类加载器
parent.loadClass(name, false)
- 当父类加载器加载失败时
- catch ClassNotFoundException 但不做任何处理
- 调用自己的 findClass() 去加载
- 我们在实现自己的类加载器时只需要
extends ClassLoader
,然后重写findClass()
方法而不是loadClass()
方法,这样就不用重写loadClass()
中的双亲委派机制了
- 我们在实现自己的类加载器时只需要
- 优点
- Java中的类随着它的类加载器一起具备了带有优先级的层次关系,例如类java.lang.Object,他存放在rt.jar之中,无论哪一个类加载器都要加载这个类,最终都是委派给启动类加载器进行加载,因此Object类在程序各种类加载环境中都能保证是同一个类,反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个Object的类,并放在ClassPath中,那么会出现多个不同的Object类,程序会一片混乱
- 破坏双亲委派模型
- 重写loadClass()
02-虚拟机字节码执行引擎
00-虚拟机栈栈帧结构
局部变量表
- 存放方法参数和方法内部定义的局部变量;
- Java 程序编译为 class 文件时,就确定了每个方法需要分配的局部变量表的最大容量。
- 最小单位:Slot;
- 一个 Slot 中可以存放:boolean,byte,char,short,int,float,reference,returnAddress (少见);
- 虚拟机可通过局部变量表中的 reference 做到:
- 查找 Java 堆中的实例对象的起始地址;
- 查找方法区中的 Class 对象。
局部变量表的空间分配
Slot 的复用
定义: 如果当前位置已经超过某个变量的作用域时,例如出了定义这个变量的代码块,这个变量对应的 Slot 就可以给其他变量使用了。但同时也说明,只要其他变量没有使用这部分 Slot 区域,这个变量就还保存在那里,这会对 GC 操作产生影响。
对 GC 操作的影响:
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
-verbose:gc
输出:
[GC (System.gc()) 68813K->66304K(123904K), 0.0034797 secs]
[Full GC (System.gc()) 66304K->66204K(123904K), 0.0086225 secs] // 没有被回收
进行如下修改:
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 1; // 新加一个赋值操作
System.gc();
}
-verbose:gc
输出:
[GC (System.gc()) 68813K->66320K(123904K), 0.0017394 secs]
[Full GC (System.gc()) 66320K->668K(123904K), 0.0084337 secs] // 被回收了
第二次修改后,placeholder 能被回收的原因?
- placeholder 能否被回收的关键:局部变量表中的 Slot 是否还存在关于 placeholder 的引用;
- 出了 placeholder 所在的代码块后,还没有进行其他操作,所以 placeholder 所在的 Slot 还没有被其他变量复用,也就是说,局部变量表的 Slot 中依然存在着 placeholder 的引用;
- 第二次修改后,int a 占用了原来 placeholder 所在的 Slot,所以可以被 GC 掉了。
操作数栈
- 元素可以是任意 Java 类型,32 位数据占 1 个栈容量,64 位数据占 2 个栈容量;
- Java 虚拟机的解释执行称为:基于栈的执行引擎,其中 “栈” 指的就是操作数栈;
- 譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的, 又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。 举个例子, 例如整数加法的字节码指令iadd, 这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值, 当执行这个指令时, 会把这两个int值出栈并相加, 然后将相加的结果重新入栈。
动态连接
- 指向运行时常量池中该栈帧所属方法的引用;
- 为了支持方法调用过程中的动态连接。
方法返回地址
- 两种退出方法的方式:
- 遇到 return;
- 遇到异常。
- 退出方法时可能执行的操作:
- 恢复上层方法的局部变量表和操作数栈;
- 把返回值压入调用者栈帧的操作数栈;
- 调整 PC 计数器指向方法调用后面的指令。
01-方法调用
Java 的方法的执行分为两个部分:
- 方法调用:确定被调用的方法是哪一个;
- 基于栈(操作数栈)的解释执行:真正的执行方法的字节码。
在本节中我们将对方法调用进行详细的讲解,我们知道,一切方法的调用在 Class 文件中存储的都是常量池中的符号引用,而不是方法实际运行时的入口地址(直接引用),直到类加载的时候,甚至是实际运行的时候才回去会去确定要被运行的方法的直接引用,而确定要被运行的方法的直接引用的过程就叫做方法调用。
方法调用字节码指令
Java 虚拟机提供了 5 个职责不同的方法调用字节码指令:
invokestatic
:调用静态方法;invokespecial
:调用构造器方法、私有方法、父类方法;invokevirtual
:调用所有虚方法,除了静态方法、构造器方法、私有方法、父类方法、final 方法的其他方法叫虚方法;invokeinterface
:调用接口方法,会在运行时确定一个该接口的实现对象;invokedynamic
:在运行时动态解析出调用点限定符引用的方法,再执行该方法。
除了 invokedynamic
,其他 4 种方法的第一个参数都是被调用的方法的符号引用,是在编译时确定的,所以它们缺乏动态类型语言支持,因为动态类型语言只有在运行期才能确定接收者的类型,即变量的类型检查的主体过程在运行期,而非编译期。
final 方法虽然是通过
invokevirtual
调用的,但是其无法被覆盖,没有其他版本,无需对接收者进行多态选择,或者说多态选择的结果是唯一的,所以属于非虚方法。
解析调用
解析调用,正如其名,就是 在类加载的解析阶段,就确定了方法的调用版本 。我们知道类加载的解析阶段会将一部分符号引用转化为直接引用,这一过程就叫做解析调用。因为是在程序真正运行前就确定了要调用哪一个方法,所以 解析调用能成立的前提就是:方法在程序真正运行前就有一个明确的调用版本了,并且这个调用版本不会在运行期发生改变。
符合这两个要求的只有以下两类方法:
- 通过
invokestatic
调用的方法:静态方法; - 通过
invokespecial
调用的方法:私有方法、构造器方法、父类方法;
这两类方法根本不可能通过继承或者别的方式重写出来其他版本,也就是说,在运行前就可以确定调用版本了,十分适合在类加载阶段就解析好。它们会在类加载的解析阶被解析为直接引用,即确定调用版本。
分派调用
在介绍分派调用前,我们先来介绍一下 Java 所具备的面向对象的 3 个基本特征:封装,继承,多态。
其中多态最基本的体现就是重载和重写了,重载和重写的一个重要特征就是方法名相同,其他各种不同:
- 重载:发生在同一个类中,入参必须不同,返回类型、访问修饰符、抛出的异常都可以不同;
- 重写:发生在子父类中,入参和返回类型必须相同,访问修饰符大于等于被重写的方法,不能抛出新的异常。
相同的方法名实际上给虚拟机的调用带来了困惑,因为虚拟机需要判断,它到底应该调用哪个方法,而这个过程会在分派调用中体现出来。其中:
- 方法重载 —— 静态分派
- 方法重写 —— 动态分派
静态分派(方法重载)
在介绍静态分派前,我们先来介绍一下什么是变量的静态类型和实际类型。
变量的静态类型和实际类型
public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(Human guy) {
System.out.println("Hello guy!");
}
public void sayHello(Man man) {
System.out.println("Hello man!");
}
public void sayHello(Woman woman) {
System.out.println("Hello woman!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
/* 输出:
Hello guy!
Hello guy!
因为是根据变量的静态类型,也就是左面的类型:Human 来判断调用哪个方法,
所以调用的都是 public void sayHello(Human guy)
*/
}
}
/* 简单讲解 */
// 使用
Human man = new Man();
// 实际类型发生变化
Human man = new Man();
man = new Woman();
// 静态类型发生变化
sr.sayHello((Man) man); // 输出:Hello man!
sr.sayHello((Woman) man); // 输出:Hello woman!
其中 Human
称为变量的静态类型,Man
称为变量的实际类型。
在重载时,编译器是通过方法参数的静态类型,而不是实际类型,来判断应该调用哪个方法的。
通俗的讲,静态分派就是通过方法的参数(类型 & 个数 & 顺序)这种静态的东西来判断到底调用哪个方法的过程。
重载方法匹配优先级,例如一个字符 'a' 作为入参
- 基本类型
- char
- int
- long
- float
- double
- Character
- Serializable(Character 实现的接口)
- 同时出现两个优先级相同的接口,如 Serializable 和 Comparable,会提示类型模糊,拒绝编译。
- Object
- char...(变长参数优先级最低)
动态分派(方法重写)
动态分派就是在运行时,根据实际类型确定方法执行版本的分派过程。
动态分派的过程
我们先来看一个例子:
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
protected void sayHello() {
System.out.println("Hello man");
}
}
static class Woman extends Human {
protected void sayHello() {
System.out.println("Hello woman");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = woman;
man.sayHello();
/* 输出
Hello man
Hello woman
Hello woman
*/
}
}
字节码分析:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class com/jvm/ch8/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method com/jvm/ch8/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class com/jvm/ch8/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method com/jvm/ch8/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1 // 把刚创建的对象的引用压到操作数栈顶,
// 供之后执行sayHello时确定是执行哪个对象的sayHello
17: invokevirtual #6 // 方法调用
20: aload_2 // 把刚创建的对象的引用压到操作数栈顶,
// 供之后执行sayHello时确定是执行哪个对象的sayHello
21: invokevirtual #6 // 方法调用
24: aload_2
25: astore_1
26: aload_1
27: invokevirtual #6 // Method com/jvm/ch8/DynamicDispatch$Human.sayHello:()V
30: return
通过字节码分析可以看出,invokevirtual
指令的运行过程大致为:
- 去操作数栈顶取出将要执行的方法的所有者,记作 C;
- 查找此方法:
- 在 C 中查找此方法;
- 在 C 的各个父类中查找;
- 查找过程:
- 查找与常量的描述符和简单名称都相同的方法;
- 进行访问权限验证,不通过抛出:IllegalAccessError 异常;
- 通过访问权限验证则返回直接引用;
- 没找到则抛出:AbstractMethodError 异常,即该方法没被实现。
动态分派的实现
动态分派在虚拟机种执行的非常频繁,而且方法查找的过程要在类的方法元数据中搜索合适的目标,从性能上考虑,不太可能进行如此频繁的搜索,需要进行性能上的优化。
常用优化手段: 在类的方法区中建立一个虚方法表。
- 虚方法表中存放着各个方法的实际入口地址,如果某个方法没有被子类方法重写,那子类方法表中该方法的入口地址 = 父类方法表中该方法的入口地址;
- 使用这个方法表索引代替在元数据中查找;
- 该方法表会在类加载的连接阶段初始化好。
通俗的讲,动态分派就是通过方法的接收者这种动态的东西来判断到底调用哪个方法的过程。
总结一下:静态分派看左面,动态分派看右面。
单分派与多分派
除了静态分派和动态分派这种分派分类方式,还有一种根据宗量分类的方式,可以将方法分派分为单分派和多分派。
宗量:方法的接收者 & 方法的参数。
Java 语言的静态分派属于多分派,根据 方法接收者的静态类型 和 方法参数类型 两个宗量进行选择。
Java 语言的动态分派属于单分派,只根据 方法接收者的实际类型 一个宗量进行选择。
动态类型语言支持
什么是动态类型语言?
就是类型检查的主体过程在运行期,而非编译期的编程语言。
动/静态类型语言各自的优点?
- 动态类型语言:灵活性高,开发效率高。
- 静态类型语言:编译器提供了严谨的类型检查,类型相关的问题能在编码的时候就发现。
Java虚拟机层面提供的动态类型支持:
invokedynamic
指令- java.lang.invoke 包
java.lang.invoke 包
目的: 在之前的依靠符号引用确定调用的目标方法的方式之外,提供了 MethodHandle 这种动态确定目标方法的调用机制。
MethodHandle 的使用
-
获得方法的参数描述,第一个参数是方法返回值的类型,之后的参数是方法的入参:
MethodType mt = MethodType.methodType(void.class, String.class);
-
获取一个普通方法的调用:
/** * 需要的参数: * 1. 被调用方法所属类的类对象 * 2. 方法名 * 3. MethodType 对象 mt * 4. 调用该方法的对象 */ MethodHandle.lookup().findVirtual(receiver.getClass(), "方法名", mt).bindTo(receiver);
-
获取一个父类方法的调用:
/** * 需要的参数: * 1. 被调用方法所属类的类对象 * 2. 方法名 * 3. MethodType 对象 mt * 4. 调用这个方法的类的类对象 */ MethodHandle.lookup().findSpecial(GrandFather.class, "方法名", mt, getClass());
-
通过
MethodHandle mh
执行方法:/* invoke() 和 invokeExact() 的区别: - invokeExact() 要求更严格,要求严格的类型匹配,方法的返回值类型也在考虑范围之内 - invoke() 允许更加松散的调用方式 */ mh.invoke("Hello world"); mh.invokeExact("Hello world");
使用示例:
public class MethodHandleTest {
static class ClassA {
public void println(String s) {
System.out.println(s);
}
}
public static void main(String[] args) throws Throwable {
/*
obj的静态类型是Object,是没有println方法的,所以尽管obj的实际类型都包含println方法,
它还是不能调用println方法
*/
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
/*
invoke()和invokeExact()的区别:
- invokeExact()要求更严格,要求严格的类型匹配,方法的返回值类型也在考虑范围之内
- invoke()允许更加松散的调用方式
*/
getPrintlnMH(obj).invoke("Hello world");
getPrintlnMH(obj).invokeExact("Hello world");
}
private static MethodHandle getPrintlnMH(Object receiver)
throws NoSuchMethodException, IllegalAccessException {
/* MethodType代表方法类型,第一个参数是方法返回值的类型,之后的参数是方法的入参 */
MethodType mt = MethodType.methodType(void.class, String.class);
/*
lookup()方法来自于MethodHandles.lookup,
这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄
*/
/*
因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,
也即是this指向的对象,这个参数以前是放在参数列表中进行传递,现在提供了bindTo()方法来完成这件事情
*/
return MethodHandles.lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver);
}
}
MethodHandles.lookup 中 3 个方法对应的字节码指令:
findStatic()
:对应 invokestaticfindVirtual()
:对应 invokevirtual & invokeinterfacefindSpecial()
:对应 invokespecial
MethodHandle 和 Reflection 的区别
- 本质区别:它们都在模拟方法调用,但是
- Reflection 模拟的是 Java 代码层次的调用;
- MethodHandle 模拟的是字节码层次的调用。
- 包含信息的区别:
- Reflection 的 Method 对象包含的信息多,包括:方法签名、方法描述符、方法的各种属性的Java端表达方式、方法执行权限等;
- MethodHandle 对象包含的信息比较少,既包含与执行该方法相关的信息。
invokedynamic` 指令
Lambda 表达式就是通过 invokedynamic
指令实现的。
02-基于栈的字节码解释引擎
这个栈,就是栈帧中的操作数栈。
解释执行
先通过 javac 将代码编译成字节码,虚拟机再通过加载字节码文件,解释执行字节码文件生成机器码,解释执行的流程如下:
词法分析 -> 语法分析 -> 形成抽象语法树 -> 遍历语法树生成线性字节码指令流
指令集分类
基于栈的指令集
-
优点:
- 可移植:寄存器由硬件直接提供,程序如果直接依赖这些硬件寄存器,会不可避免的受到硬件的约束;
- 代码更紧凑:字节码中每个字节对应一条指令,多地址指令集中还需要存放参数;
- 编译器实现更简单:不需要考虑空间分配问题,所需的空间都在栈上操作。
-
缺点: 执行速度稍慢
- 完成相同的功能,需要更多的指令,因为出入栈本身就产生相当多的指令;
- 频繁的栈访问导致频繁的内存访问,对于处理器而言,内存是执行速度的瓶颈。
-
示例: 两数相加
iconst_1 // 把常量1入栈 iconst_1 iadd // 把栈顶两元素出栈相加,结果入栈 istore_0 // 把栈顶值存入第0个Slot中
基于寄存器的指令集
示例: 两数相加
mov eax, 1
add eax, 1
执行过程分析
public class Architecture {
/*
calc函数的字节码分析:
public int calc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1 // stack=2,说明需要深度为2的操作数栈
// locals=4,说明需要4个Slot的局部变量表
0: bipush 100 // 将单字节的整型常数值push到操作数栈
2: istore_1 // 将操作数栈顶的整型值出栈并存放到第一个局部变量Slot中
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1 // 将局部变量表第一个Slot中的整型值复制到操作数栈顶
12: iload_2
13: iadd // 将操作数栈中头两个元素出栈并相加,将结果重新入栈
14: iload_3
15: imul // 将操作数栈中头两个元素出栈并相乘,将结果重新入栈
16: ireturn // 返回指令,结束方法执行,将操作数栈顶的整型值返回给此方法的调用者
*/
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
public static void main(String[] args) {
Architecture architecture = new Architecture();
architecture.calc();
}
}
附录-实现Java类的热替换
什么是热替换及其实现原理
- 热替换是在不停止正在运行的系统的情况下进行类(对象)的升级替换;
- 这要求虚拟机中要存在同一个类的两个不同版本。可我们知道,我们是无法将同一个类加载两遍的,想要实现这点,我们需要让虚拟机认为这是两个不同的类,即用两个不同的类加载器去加载这个类不同版本的 class 文件;
- 因此,这个工作就不能由系统提供给我们的启动类加载器,扩展类加载器或者应用程序类加载器来完成,因为这三个类加载器在同一个虚拟机中只有一份,不仅如此,我们还要跳过这些类加载器;
- 想要跳过这些类加载器可不是只要不用这些类加载器就行了,还需要我们跳过双亲委派模型,否则类的加载还会被委派到这些个类加载器,如果恰好某个类之前是由这三个类加载器中的一个加载的,虚拟机就不会再次加载新版本的类了,就无法实现类的热替换了。
实现简单的 Java 类热替换
需求分析
现有一 Foo 类,可以在控制台持续打印:Hello world! version one,我们将在该类运行时,将其 .class 文件替换为修改后的 Foo 类的 .class 文件,修改后的 Foo 会在控制台持续打印:Hello world! version two。也就是说,替换之后,控制台打印的内容发生变化,就说明类的热替换实现成功。
Foo 类的实现:
public class Foo {
public void sayHello() {
System.out.println("Hello world! version one");
// System.out.println("Hello world! version two"); // 之后替换成这个
}
}
然后我们通过如下程序运行 Foo 类:
public class Task extends TimerTask {
@Override
public void run() {
String basePath = "C:\\Users\\Bean\\IdeaProjects\\USTJ\\target\\classes";
// 每执行一次任务都 new 一个新的类加载器
HotswapClassLoader cl = new HotswapClassLoader(
basePath, new String[]{"com.jvm.ch7.hotswap.Foo"});
try {
// 通过我们自己实现的类加载器加载 Foo 类
Class cls = cl.loadClass("com.jvm.ch7.hotswap.Foo", true);
Object foo = cls.newInstance();
Method method = cls.getMethod("sayHello", new Class[]{});
method.invoke(foo, new Object[]{});
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new Task(), 0, 1000);
}
}
实现类加载器
HotswapClassLoader
的实现如下,具体的讲解已被写入注释中:
public class HotswapClassLoader extends ClassLoader {
private String basePath;
private HashSet<String> loadedClass; // 用来记录被这个类加载器所加载的类
public HotswapClassLoader(String basePath, String[] classList) {
// 跳过父类加载器,把它设为null
super(null);
this.basePath = basePath;
loadedClass = new HashSet<>();
// 该类加载器在初始化的时候会直接把应该它负责加载的类加载好,
// 这样之后 loadClass 时,会在第一步检验该类是否已经被加载时发现该类已经被加载过了,
// 就无需执行 loadClass 之后的流程,直接返回虚拟机中被加载好的类即可,
// 这样虽然初始化的时间长了点,但是之后 loadClass 时会比较省时间
loadClassByMe(classList);
}
/**
* 加载给定的的 classList 中的类到虚拟机
*/
private void loadClassByMe(String[] classList) {
for (int i = 0; i < classList.length; i++) {
Class cls = loadClassDirectly(classList[i]);
if (cls != null) {
loadedClass.add(classList[i]);
}
}
}
/**
* 通过文件名直接加载类,得到Class对象
*/
private Class loadClassDirectly(String className) {
Class cls = null;
StringBuilder sb = new StringBuilder(basePath);
String classPath = className.replace(".", File.separator) + ".class";
sb.append(File.separator + classPath);
File file = new File(sb.toString());
InputStream fin = null;
try {
fin = new FileInputStream(file);
// 将字节流转化成内存中的Class对象
cls = instantiateClass(className, fin, (int) file.length());
return cls;
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fin != null) {
try {
fin.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
/**
* 将字节流转化成内存中的Class对象啊,使用defineClass方法!
*/
private Class instantiateClass(String name, InputStream fin, int len) {
byte[] buffer = new byte[len];
try {
fin.read(buffer);
return defineClass(name, buffer, 0, len);
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fin != null) {
try {
fin.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
/**
* 覆盖原有的loadClass规则,
*/
public Class loadClass(String name, boolean resolve) throws ClassNotFoundException {
Class cls = null;
// 应该由 HotswapClassLoader 负责加载的类会通过下面这一行得到类的 Class 对象,
// 因为早在 HotswapClassLoader 类加载器执行构造函数时,它们就被加载好了
cls = findLoadedClass(name);
// 只有在这个类没有被加载,且!这个类不是当前这个类加载器负责加载的时候,才去使用启动类加载器
if (cls == null && !loadedClass.contains(name)) {
cls = findSystemClass(name);
}
if (cls == null) {
throw new ClassNotFoundException(name);
}
// resolveClass是进行连接操作的,即"验证+准备+解析",之后就可以进行初始化了
if (resolve) {
resolveClass(cls);
}
return cls;
}
}
Ch3-虚拟机性能监控及故障处理
00-常用虚拟机性能监控工具
JDK 命令行工具
其中的重中之重是 jstat 命令!而它最常用的参数就是 -gcutil,使用格式如下:
jstat -gcutil [pid] [intervel] [count]
输出如下:
S0
:堆上 Survivor space 0 区已使用空间的百分比S1
:堆上 Survivor space 1 区已使用空间的百分比E
:堆上 Eden 区已使用空间的百分比O
:堆上 Old space 区已使用空间的百分比P
:堆上 Perm space 区已使用空间的百分比YGC
:从程序启动到采样时发生的 Minor GC 次数YGCT
:从程序启动到采样时 Minor GC 所用的时间FGC
:从程序启动到采样时发生的 Full GC 次数FGCT
:从程序启动到采样时 Full GC 所用的时间GCT
:从程序启动到采样时 GC 的总时间
补充:ps
命令 (Linux)
对于 jps
命令,其实没必要使用,一般使用 Linux 里的 ps
就够了,ps
为我们提供了当前进程状态的一次性的查看,它所提供的查看结果并不动态连续的,如果想对进程时间监控,应该用 top
工具。
Linux 上进程的 5 种状态
- 运行 [R, Runnable]:正在运行或者在运行队列中等待;
- 中断 [S, Sleep]:休眠中, 受阻, 在等待某个条件的形成或接受到信号;
- 不可中断 [D]:收到信号不唤醒和不可运行, 进程必须等待直到有中断发生;
- 僵死 [Z, zombie]:进程已终止, 但进程描述符存在, 直到父进程调用 wait4() 系统调用后释放;
- 停止 [T, Traced or stop]:进程收到 SIGSTOP, SIGSTP, SIGTIN, SIGTOU 信号后停止运行运行。
ps -A # 列出所有进程信息(非详细信息)
ps aux # 列出所有进程的信息
ps aux | grep zsh
ps -ef # 显示所有进程信息,连同命令行
ps -ef | grep zsh
ps -u root # 显示指定用户信息
ps -l # 列出这次登录bash相关信息
ps axjf # 同时列出进程树状信息
01-JVM常见参数设置
内存设置
参数
-Xms
:初始堆大小,JVM 启动的时候,给定堆空间大小。-Xmx
:最大堆大小,如果初始堆空间不足的时候,最大可以扩展到多少。-Xmn
:设置年轻代大小。`整个堆大小 = 年轻代大小 + 年老代大小。增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun 官方推荐配置为整个堆的 3/8。-Xss
: 设置每个线程的 Java 栈大小。JDK 5 后每个线程 Java 栈大小为 1M。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在 3000~5000 左右。-XX:NewRatio=n
:设置年轻代和年老代的比值。如为 3,表示年轻代与年老代比值为 1:3。-XX:MaxTenuringThreshold
:设置垃圾最大年龄。如果设置为 0 的话,则年轻代对象不经过 Survivor 区,直接进入年老代。对于年老代比较多的应用(即 Minor GC 过后有大量对象存活的应用),可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在 Survivor 区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概率。
设置经验
-
开发过程的测试应用,要求物理内存大于 4G
-Xmx3550m -Xms3550m -Xmn2g -Xss128k
-
高并发本地测试使用,大对象相对较多(如 IO 流)
-Xmx3550m -Xms3550m -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxPermSize=160m -XX:MaxTenuringThreshold=0
-
环境: 16G 物理内存,高并发服务,重量级对象中等(线程池,连接池等),常用对象比例为 40%(即运行过程中产生的对象 40% 是生命周期较长的)
-Xmx10G -Xms10G -Xss1M -XX:NewRatio=3 -XX:SurvivorRatio=4 -XX:MaxPermSize=2048m -XX:MaxTenuringThreshold=5
收集器设置
JDK1.8中默认使用的是Parallel Scavenge和Parallel Old收集器组合。
参数
- 收集器设置
-XX:+UseSerialGC
:设置串行收集器,年轻代收集器。-XX:+UseParallelGC
:设置并行收集器。-XX:+UseParNewGC
:设置年轻代为并行收集。可与 CMS 收集同时使用。JDK 5.0 以上,JVM 会根据系统配置自行设置,所以无需再设置此值。-XX:+UseParallelOldGC
:设置并行年老代收集器,JDK6.0 支持对年老代并行收集。-XX:+UseConcMarkSweepGC
:设置年老代并发收集器,测试中配置这个以后,-XX:NewRatio
的配置失效,原因不明。所以,此时年轻代大小最好用-Xmn
设置。-XX:+UseG1GC
:设置 G1 收集器。
- 并行收集器参数设置
-XX:ParallelGCThreads=n
:设置并行收集器收集时最大线程数使用的 CPU 数。并行收集线程数。-XX:MaxGCPauseMillis=n
:设置并行收集最大暂停时间,单位毫秒。-XX:GCTimeRatio=n
:设置垃圾回收时间占程序运行时间的百分比。-XX:+UseAdaptiveSizePolicy
:设置此选项后,并行收集器会自动选择年轻代区大小和相应的 Survivor 区比例,以达到目标系统规定的最低相应时间或者收集频率等,此值建议使用并行收集器时,一直打开。-XX:CMSFullGCsBeforeCompaction=n
:由于 CMS 不对内存空间进行压缩、整理,所以运行一段时间以后会产生"碎片",使得运行效率降低。此值设置运行多少次 GC 以后对内存空间进行压缩、整理。-XX:+UseCMSCompactAtFullCollection
:打开对年老代的压缩。可能会影响性能,但是可以消除碎片。
02-虚拟机调优案例分析
高性能硬件上的程序部署策略
补充:64 位虚拟机
在 Java EE 方面,企业级应用经常需要使用超过 4GB 的内存,此时,32 位虚拟机将无法满足需求,可是 64 位虚拟机虽然可以设置更大的内存,却存在以下缺点:
- 内存问题: 由于指针膨胀和各种数据类型对齐补白的原因,运行于 64 位系统上的 Java 应用程序需要消耗更多的内存,通常要比 32 位系统额外增加 10% ~ 30% 的内存消耗。
- 性能问题: 64 位虚拟机的运行速度在各个测试项中几乎全面落后于 32 位虚拟机,两者大概有 15% 左右的性能差距。
服务系统经常出现卡顿(Full GC 时间太长)
首先 jstat -gcutil
观察 GC 的耗时,jstat -gccapacity
检查内存用量(也可以加上 -verbose:gc
参数获取 GC 的详细日志),发现卡顿是由于 Full GC 时间太长导致的,然后 jinfo -v pid
,查看虚拟机参数设置,发现 -XX:NewRatio=9
,这就是原因:
- 新生代太小,对象提前进入老年代,触发 Full GC
- 老年代较大,一次 Full GC 时间较长
可以调小 NewRatio 的值,尽可能让比较少的对象进入老年代。
除了 Java 堆和永久代之外,会占用较多内存的区域
区域 | 大小调整 / 说明 | 内存不足时抛出的异常 |
---|---|---|
直接内存 | -XX:MaxDirectMemorySize | OutOfMemoryError: Direct buffer memory |
线程堆栈 | -Xss | StackOverflowError 或 OutOfMemoryError: unable to create new native thread |
Socket 缓存区 | 每个 Socket 连接都有 Receive(37KB) 和 Send(25KB) 两个缓存区 | IOException: Too many open files |
JNI 代码 | 如果代码中使用 JNI 调用本地库,那本地库使用的内存也不在堆中 | |
虚拟机和 GC | 虚拟机、GC 代码执行要消耗一定内存 |
从 GC 调优角度解决新生代存活大量对象问题(Minor GC 时间太长)
- 将 Survivor 空间去除,让新生代中存活的对象在第一次 Minor GC 后立刻进入老年代,等到 Full GC 时再清理。
- 参数调整方法:
-XX:SurvivorRatio=65536
-XX:MaxTenuringThreshold=0
-XX:AlwaysTenure
G1预测STW的原理
https://www.pianshen.com/article/43741671771/
Ch4-Java程序运行优化
这里只讲运行期优化
我们知道,Java 是解释执行的,可是解释执行毕竟还是有点慢的,这也使得 Java 一直被认为是效率低下的语言……,不过随着即时编译技术的发展,Java 的运行速度得到了很大的提升,在本篇文章中,我们将会对 Java 的运行期优化,也就是即时编译 (Just In Time, JIT) 时进行的优化进行详细的讲解,我们先来看看什么是即时编译。
即时编译
什么是即时编译?
- 当虚拟机发现某个方法或某段代码运行的特别频繁时,会把这段代码认为成热点代码;
- 在运行时,虚拟机会将这段代码编译成平台相关的机器码,并进行各种层次的优化。
HotSpot 虚拟机内的即时编译器运作过程
我们主要通过以下 5 个问题来了解 HotSpot 虚拟机的即时编译器。
为什么要使用解释器与编译器并存的架构?
- 解释器的优点:可以提高程序的响应速度(省去了编译的时间),并且节约内存。
- 编译器的优点:可以提高执行效率。
- 虚拟机参数设置:
- 强制运行于解析模式:
-Xint
,编译器完全不工作; - 强制运行于编译模式:
-Xcomp
,当编译器编译失败时,解释执行还是会介入的。
- 强制运行于解析模式:
为什么虚拟机要实现两个不同的 JIT 编译器?
- Client Compiler(C1):不激进优化;
- Server Compiler(C2):激进优化,如果激进优化不成立,再退回为解释执行或者 C1 编译器执行。
什么是虚拟机的分层编译?
分层编译就是根据编译器编译、优化的规模与耗时,划分出不同的编译层次,在代码运行的过程中,可以动态的选择将某一部分代码片段提升一个编译层次或者降低一个编译层次。
C1 与 C2 编译器会同时工作,许多代码可能会被多次编译。
目的: 在程序的启动响应时间和运行效率间达到平衡。
编译层次的划分:
- 第 0 层:解释执行,不开启性能监控;
- 第 1 层:将字节编译为机器码,但不进行激进优化,有必要时会加入性能监控;
- 第 2 层及以上:将字节编译为机器码,会根据性能监控信息进行激进优化。
如何判断热点代码,触发编译?
什么是热点代码?
- 被多次调用的方法;
- 被多次执行的循环体;
- 虽然被判断为热点代码的是循环体,不过因为虚拟机的即时编译是以方法为单位的,所以编译器还是会将循环体所在的方法整个作为编译对象。
我们发现,判断热点代码的一个要点就是: 多次执行 。那么虚拟机是如何知道一个方法或者一个循环体被多次执行的呢?
什么是 “多次” 执行?
- 基于采样的热点探测
- 虚拟机周期检查各个线程的栈顶,如果发现一个方法经常出现在栈顶,则该方法为热点方法。
- 优点: 容易获取方法的调用关系,且简单高效。
- 缺点: 无法精准的判断一个方法的热度,并且容易受到线程阻塞的影响,如果一个方法由于它所在的线程被阻塞的缘故而一直出现在栈顶,我们并不能认为这个方法被调用的十分频繁。
- 基于计数器的热点探测
- 虚拟机为每一个方法(或代码块)建立一个计数器,一旦执行次数超过一定阈值,就将其判为热点代码。
- 优点: 精确严谨。
- 缺点: 不能直接获取方法的调用关系,且实现复杂。
- HotSpot 使用的是这个,并且还为每个方法建立了两个计数器。
HotSpot 中每个方法的 2 个计数器
-
方法调用计数器
- 统计方法被调用的次数,处理多次调用的方法的。
- 默认统计的不是方法调用的绝对次数,而是方法在一段时间内被调用的次数,如果超过这个时间限制还没有达到判为热点代码的阈值,则该方法的调用计数器值减半。
- 关闭热度衰减:
-XX: -UseCounterDecay
(此时方法计数器统计的是方法被调用的绝对次数); - 设置半衰期时间:
-XX: CounterHalfLifeTime
(单位是秒); - 热度衰减过程是在 GC 时顺便进行。
- 关闭热度衰减:
-
回边计数器
-
统计一个方法中 “回边” 的次数,处理多次执行的循环体的。
- 回边:在字节码中遇到控制流向后跳转的指令(不是所有循环体都是回边,空循环体是自己跳向自己,没有向后跳,不算回边)。
-
调整回边计数器阈值:
-XX: OnStackReplacePercentage
(OSR比率)
- Client 模式:
回边计数器的阈值 = 方法调用计数器阈值 * OSR比率 / 100
; - Server 模式:
回边计数器的阈值 = 方法调用计数器阈值 * ( OSR比率 - 解释器监控比率 ) / 100
;
- Client 模式:
-
HotSpot 热点代码探测流程
热点代码编译的过程?
虚拟机在代码编译未完成时会按照解释方式继续执行,编译动作在后台的编译线程执行。
禁止后台编译:-XX: -BackgroundCompilation
,打开后这个开关参数后,交编译请求的线程会等待编译完成,然后执行编译器输出的本地代码。
经典优化技术介绍
Content:
- 公共子表达式消除【语言无关】
- 数组范围检查消除【语言相关】
- 方法内联【最重要】
- 逃逸分析【最前沿】
公共子表达式消除【语言无关】
如果一个表达式 E 已经计算过了,并且从先前的计算到现在,E 中所有变量值都没有发生变化,则 E 为公共子表达式,无需再次计算,直接用之前的结果替换。
数组范围检查消除【语言相关】
在循环中使用循环变量访问数组,如果可以判断循环变量的范围在数组的索引范围内,则可以消除整个循环的数组范围检查
方法内联【最重要】
目的是:去除方法调用的成本(如建立栈帧等),并为其他优化建立良好的基础,所以一般将方法内两放在优化序列最前端,因为它对其他优化有帮助。
类型继承关系分析(Class Hierarchy Analysis,CHA)
用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等。
-
对于非虚方法:
- 直接进行内联,其调用方法的版本在编译时已经确定,是根据变量的静态类型决定的。
-
对于虚方法:
(激进优化,要预留“逃生门”)
- 向 CHA 查询此方法在当前程序下是否有多个目标可选择;
- 只有一个目标版本:
- 先对这唯一的目标进行内联;
- 如果之后的执行中,虚拟机没有加载到会令这个方法接收者的继承关系发生改变的新类,则该内联代码可以一直使用;
- 如果加载到导致继承关系发生变化的新类,就抛弃已编译的代码。
- 有多个目标版本:
- 使用内联缓存,未发生方法调用前,内联缓存为空;
- 第一次调用发生后,记录调用方法的对象的版本信息;
- 之后的每次调用都要先与内联缓存中的对象版本信息进行比较;
- 版本信息一样,继续使用内联代码;
- 版本信息不一样,说明程序使用了虚方法的多态特性,这时取消内联,查找虚方法进行方法分派。
- 只有一个目标版本:
- 向 CHA 查询此方法在当前程序下是否有多个目标可选择;
逃逸分析【最前沿】
基本行为
分析对象的作用域,看它有没有能在当前作用域之外使用:
- 方法逃逸:对象在方法中定义之后,能被外部方法引用,如作为参数传递到了其他方法中。
- 线程逃逸:赋值给 static 变量,或可以在其他线程中访问的实例变量。
对于不会逃逸到方法或线程外的对象能进行优化
- 栈上分配: 对于不会逃逸到方法外的对象,可以在栈上分配内存,这样这个对象所占用的空间可以随栈帧出栈而销毁,减小 GC 的压力。
- 标量替换(重要):
- 标量:基本数据类型和 reference。
- 不创建对象,而是将对象拆分成一个一个标量,然后直接在栈上分配,是栈上分配的一种实现方式。
- HotSpot 使用的是标量替换而不是栈上分配,因为实现栈上分配需要更改大量假设了 “对象只能在堆中分配” 的代码。
- 锁消除: 不会逃逸到线程外的方法不需要进行同步。
虚拟机参数
- 开启逃逸分析:
-XX: +DoEscapeAnalysis
- 开启标量替换:
-XX: +EliminateAnalysis
- 开启锁消除:
-XX: +EliminateLocks
- 查看分析结果:
-XX: PrintEscapeAnalysis
- 查看标量替换情况:
-XX: PrintEliminateAllocations
一个优化的例子
原始代码:
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}
第一步优化: 方法内联(一般放在优化序列最前端,因为对其他优化有帮助)
目的:
- 去除方法调用的成本(如建立栈帧等)
- 为其他优化建立良好的基础
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}
第二步优化: 公共子表达式消除
public void foo() {
y = b.value;
// ...do stuff... // 因为这部分并没有改变 b.value 的值
// 如果把 b.value 看成一个表达式,就是公共表达式消除
z = y; // 把这一步的 b.value 替换成 y
sum = y + z;
}
第三步优化: 复写传播
public void foo() {
y = b.value;
// ...do stuff...
y = y; // z 变量与以相同,完全没有必要使用一个新的额外变量
// 所以将 z 替换为 y
sum = y + z;
}
第四步优化: 无用代码消除
无用代码:
- 永远不会执行的代码
- 完全没有意义的代码
public void foo() {
y = b.value;
// ...do stuff...
// y = y; 这句没有意义的,去除
sum = y + y;
}