最近,一直有小伙伴让我整理下关于 JVM 的知识,经过十几天的收集与整理,初版算是整理出来了。希望对大家有所帮助。
JDK 是用于支持 Java 程序开发的最小环境。
JRE 是支持 Java 程序运行的标准环境。
程序计数器(Program Counter Register)是一块较小的内存空间,可以看作是当前线程所执行字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式实现的。为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各线程之间的计数器互不影响,独立存储。
程序计数器是唯一一个没有规定任何 OutOfMemoryError 的区域。
Java 虚拟机栈(Java Virtual Machine Stacks)是线程私有的,生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法被执行的时候都会创建一个栈帧(Stack Frame),存储
每一个方法被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
这个区域有两种异常情况:
虚拟机栈为虚拟机执行 Java 方法(字节码)服务。
本地方法栈(Native Method Stacks)为虚拟机使用到的 Native 方法服务。
Java 堆(Java Heap)是 Java 虚拟机中内存最大的一块。Java 堆在虚拟机启动时创建,被所有线程共享。
作用:存放对象实例。垃圾收集器主要管理的就是 Java 堆。Java 堆在物理上可以不连续,只要逻辑上连续即可。
方法区(Method Area)被所有线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
和 Java 堆一样,不需要连续的内存,可以选择固定的大小,更可以选择不实现垃圾收集。
运行时常量池(Runtime Constant Pool)是方法区的一部分。保存 Class 文件中的符号引用、翻译出来的直接引用。运行时常量池可以在运行期间将新的常量放入池中。
Object obj = new Object();
对于上述最简单的访问,也会涉及到 Java 栈、Java 堆、方法区这三个最重要内存区域。
Object obj
如果出现在方法体中,则上述代码会反映到 Java 栈的本地变量表中,作为 reference 类型数据出现。
new Object()
反映到 Java 堆中,形成一块存储了 Object 类型所有对象实例数据值的内存。Java 堆中还包含对象类型数据的地址信息,这些类型数据存储在方法区中。
给对象添加一个引用计数器,每当有一个地方引用它,计数器就+1,;当引用失效时,计数器就-1;任何时刻计数器都为 0 的对象就是不能再被使用的。
很难解决对象之间的循环引用问题。
通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。
在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为
Object obj = new Object();
代码中普遍存在的,像上述的引用。只要强引用还在,垃圾收集器永远不会回收掉被引用的对象。
用来描述一些还有用,但并非必须的对象。软引用所关联的对象,有在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围,并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存异常。提供了 SoftReference 类实现软引用。
描述非必须的对象,强度比软引用更弱一些,被弱引用关联的对象,只能生存到下一次垃圾收集发生前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。提供了 WeakReference 类来实现弱引用。
一个对象是否有虚引用,完全不会对其生存时间够成影响,也无法通过虚引用来取得一个对象实例。为一个对象关联虚引用的唯一目的,就是希望在这个对象被收集器回收时,收到一个系统通知。提供了 PhantomReference 类来实现虚引用。
分为标记和清除两个阶段。首先标记出所有需要回收的对象,在标记完成后统一回收被标记的对象。
效率问题:标记和清除过程的效率都不高。
空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致,程序分配较大对象时无法找到足够的连续内存,不得不提前出发另一次垃圾收集动作。
将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将存活着的对象复制到另一块上面,然后再把已经使用过的内存空间一次清理掉。
复制算法使得每次都是针对其中的一块进行内存回收,内存分配时也不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
将内存缩小为原来的一半。在对象存活率较高时,需要执行较多的复制操作,效率会变低。
商业的虚拟机都采用复制算法来回收新生代。因为新生代中的对象容易死亡,所以并不需要按照 1:1 的比例划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间。每次使用 Eden 和其中的一块 Survivor。
当回收时,将 Eden 和 Survivor 中还存活的对象一次性拷贝到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。Hotspot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80% + 10%),只有 10%的内存是会被“浪费”的。
标记过程仍然与“标记-清除”算法一样,但不是直接对可回收对象进行清理,而是让所有存活的对象向一端移动,然后直接清理掉边界以外的内存。
根据对象的存活周期,将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点,采用最适当的收集算法。
Minor GC:新生代 GC,指发生在新生代的垃圾收集动作,因为 Java 对象大多死亡频繁,所以 Minor GC 非常频繁,一般回收速度较快。Full GC:老年代 GC,也叫 Major GC,速度一般比 Minor GC 慢 10 倍以上。
对于一个大型的系统,当创建的对象及方法变量比较多时,即堆内存中的对象比较多,如果逐一分析对象是否该回收,效率很低。分区是为了进行模块化管理,管理不同的对象及变量,以提高 JVM 的执行效率。
主要用来存储新创建的对象,内存较小,垃圾回收频繁。这个区又分为三个区域:一个 Eden Space 和两个 Survivor Space。
Tenure Generation Space(采用标记-整理算法)
主要用来存储长时间被引用的对象。它里面存放的是经过几次在 Young Generation Space 进行扫描判断过仍存活的对象,内存较大,垃圾回收频率较小。
存储不变的类定义、字节码和常量等。
Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目间没有任何分隔符。当遇到 8 位字节以上空间的数据项时,则会按照高位在前的方式分隔成若干个 8 位字节进行存储。
每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是用于确定这个文件是否为一个能被虚拟机接受的 Class 文件。OxCAFEBABE。
接下来是 Class 文件的版本号:第 5,6 字节是次版本号(Minor Version),第 7,8 字节是主版本号(Major Version)。
使用 JDK 1.7 编译输出 Class 文件,格式代码为:
前四个字节为魔数,次版本号是 0x0000,主版本号是 0x0033,说明本文件是可以被 1.7 及以上版本的虚拟机执行的文件。
类加载器实现类的加载动作,同时用于确定一个类。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性。即使两个类来源于同一个 Class 文件,只要加载它们的类加载器不同,这两个类就不相等。
双亲委派模型(Parents Delegation Model)要求除了顶层的启动类加载器外,其余加载器都应当有自己的父类加载器。类加载器之间的父子关系,通过组合关系复用。工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有到父加载器反馈自己无法完成这个加载请求(它的搜索范围没有找到所需的类)时,子加载器才会尝试自己去加载。
Java 类随着它的类加载器一起具备了一种带优先级的层次关系。比如 java.lang.Object,它存放在 rt.jar 中,无论哪个类加载器要加载这个类,最终都是委派给启动类加载器进行加载,因此 Object 类在程序的各个类加载器环境中,都是同一个类。
如果没有使用双亲委派模型,让各个类加载器自己去加载,那么 Java 类型体系中最基础的行为也得不到保障,应用程序会变得一片混乱。
Class 文件描述的各种信息,都需要加载到虚拟机后才能运行。虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
这两种机器都有代码执行的能力,但是:
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构, 存储了方法的
每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
方法调用唯一的任务是确定被调用方法的版本(调用哪个方法),暂时还不涉及方法内部的具体运行过程。
Class 文件的编译过程不包含传统编译的连接步骤,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。这使得 Java 有强大的动态扩展能力,但使 Java 方法的调用过程变得相对复杂,需要在类加载期间甚至到运行时才能确定目标方法的直接引用。
解释执行(通过解释器执行)编译执行(通过即时编译器产生本地代码)
当主流的虚拟机中都包含了即时编译器后,Class 文件中的代码到底会被解释执行还是编译执行,只有虚拟机自己才能准确判断。
Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译是半独立的实现。
Java 编译器输出的指令流,里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。
计算“1+1=2”,基于栈的指令集是这样的:
iconst_1iconst_1iaddistore_0
两条 iconst_1 指令连续地把两个常量 1 压入栈中,iadd 指令把栈顶的两个值出栈相加,把结果放回栈顶,最后 istore_0 把栈顶的值放到局部变量表的第 0 个 Slot 中。
最典型的是 x86 的地址指令集,依赖寄存器工作。计算“1+1=2”,基于寄存器的指令集是这样的:
mov eax, 1add eax, 1
mov 指令把 EAX 寄存器的值设为 1,然后 add 指令再把这个值加 1,结果就保存在 EAX 寄存器里。
优点:
缺点:
频繁的访问栈,意味着频繁的访问内存,相对于处理器,内存才是执行速度的瓶颈。
Java 程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code)。
为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器成为即时编译器(Just In Time Compiler,JIT 编译器)。
许多主流的商用虚拟机,都同时包含解释器和编译器。
如果内存资源限制较大(部分嵌入式系统),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。同时编译器的代码还能退回成解释器的代码。
因为即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间越长。
分层编译根据编译器编译、优化的规模和耗时,划分不同的编译层次,包括:
用 Client Compiler 和 Server Compiler 将会同时工作。用 Client Compiler 获取更高的编译速度,用 Server Compiler 获取更好的编译质量。
要知道一段代码是不是热点代码,是不是需要触发即时编译,这个行为称为热点探测。主要有两种方法:
统计的是一个相对的执行频率,即一段时间内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器的热度衰减,这个时间就被称为半衰周期。
普遍应用于各种编译器的经典优化技术,它的含义是:
如果一个表达式 E 已经被计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成了公共子表达式。没有必要重新计算,直接用结果代替 E 就可以了。
因为 Java 会自动检查数组越界,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑是一种性能负担。
如果数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在数组区间内,那么整个循环中就可以把数组的上下界检查消除掉,可以节省很多次的条件判断操作。
内联消除了方法调用的成本,还为其他优化手段建立良好的基础。
编译器在进行内联时,如果是非虚方法,那么直接内联。如果遇到虚方法,则会查询当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那么也可以内联,不过这种内联属于激进优化,需要预留一个逃生门(Guard 条件不成立时的 Slow Path),称为守护内联。
如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接受者的继承关系发现变化的类,那么内联优化的代码可以一直使用。否则需要抛弃掉已经编译的代码,退回到解释状态执行,或者重新进行编译。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法里面被定义后,它可能被外部方法所引用,这种行为被称为方法逃逸。被外部线程访问到,被称为线程逃逸。
运算任务,除了需要处理器计算之外,还需要与内存交互,如读取运算数据、存储运算结果等(不能仅靠寄存器来解决)。计算机的存储设备和处理器的运算速度差了几个数量级,所以不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache),作为内存与处理器之间的缓冲:将运算需要的数据复制到缓存中,让运算快速运行。当运算结束后再从缓存同步回内存,这样处理器就无需等待缓慢的内存读写了。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是引入了一个新的问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,它们又共享同一主内存。当多个处理器的运算任务都涉及同一块主内存时,可能导致各自的缓存数据不一致。为了解决一致性的问题,需要各个处理器访问缓存时遵循缓存一致性协议。同时为了使得处理器充分被利用,处理器可能会对输出代码进行乱序执行优化。Java 虚拟机的即时编译器也有类似的指令重排序优化。
Java 虚拟机的规范,用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各个平台下都能达到一致的并发效果。
定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出这样的底层细节。此处的变量包括实例字段、静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,因为这些是线程私有的,不会被共享,所以不存在竞争问题。
所以的变量都存储在主内存,每条线程还有自己的工作内存,保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存的变量。不同的线程之间也无法直接访问对方工作内存的变量,线程间变量值的传递需要通过主内存。
一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存,Java 内存模型定义了 8 种操作:
关键字 volatile 是 Java 虚拟机提供的最轻量级的同步机制。当一个变量被定义成 volatile 之后,具备两种特性:
volatile 变量在各个线程的工作内存,不存在一致性问题(各个线程的工作内存中 volatile 变量,每次使用前都要刷新到主内存)。但是 Java 里面的运算并非原子操作,导致 volatile 变量的运算在并发下一样是不安全的。
在某些情况下,volatile 同步机制的性能要优于锁(synchronized 关键字),但是由于虚拟机对锁实行的许多消除和优化,所以并不是很快。
volatile 变量读操作的性能消耗与普通变量几乎没有差别,但是写操作则可能慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
并发不一定要依赖多线程,PHP 中有多进程并发。但是 Java 里面的并发是多线程的。
线程是比进程更轻量级的调度执行单位。线程可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件 I/O),又可以独立调度(线程是 CPU 调度的最基本单位)。
操作系统支持怎样的线程模型,在很大程度上就决定了 Java 虚拟机的线程是怎样映射的。
线程调度是系统为线程分配处理器使用权的过程。
虽然 Java 线程调度是系统自动完成的,但是我们可以建议系统给某些线程多分配点时间——设置线程优先级。Java 语言有 10 个级别的线程优先级,优先级越高的线程,越容易被系统选择执行。
但是并不能完全依靠线程优先级。因为 Java 的线程是被映射到系统的原生线程上,所以线程调度最终还是由操作系统说了算。如 Windows 中只有 7 种优先级,所以 Java 不得不出现几个优先级相同的情况。同时优先级可能会被系统自行改变。Windows 系统中存在一个“优先级推进器”,当系统发现一个线程执行特别勤奋,可能会越过线程优先级为它分配执行时间。
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。
在 Java 语言里,不可变的对象一定是线程安全的,只要一个不可变的对象被正确构建出来,那其外部的可见状态永远也不会改变,永远也不会在多个线程中处于不一致的状态。
虚拟机提供了同步和锁机制。
互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。Java 中最基本的同步手段就是 synchronized 关键字,其编译后会在同步块的前后分别形成 monitorenter 和 monitorexit 两个字节码指令。这两个字节码都需要一个 Reference 类型的参数指明要锁定和解锁的对象。如果 Java 程序中的 synchronized 明确指定了对象参数,那么这个对象就是 Reference;如果没有明确指定,那就根据 synchronized 修饰的是实例方法还是类方法,去获取对应的对象实例或 Class 对象作为锁对象。在执行 monitorenter 指令时,首先要尝试获取对象的锁。
除了 synchronized 之外,还可以使用 java.util.concurrent 包中的重入锁(ReentrantLock)来实现同步。ReentrantLock 比 synchronized 增加了高级功能:等待可中断、可实现公平锁、锁可以绑定多个条件。
等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,对处理执行时间非常长的同步块很有用。
公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。synchronized 中的锁是非公平的。
互斥同步最大的问题,就是进行线程阻塞和唤醒所带来的性能问题,是一种悲观的并发策略。总是认为只要不去做正确的同步措施(加锁),那就肯定会出问题,无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略。先进行操作,如果没有其他线程征用数据,那操作就成功了;如果共享数据有征用,产生了冲突,那就再进行其他的补偿措施。这种乐观的并发策略的许多实现不需要线程挂起,所以被称为非阻塞同步。
JDK1.6 的一个重要主题,就是高效并发。HotSpot 虚拟机开发团队在这个版本上,实现了各种锁优化:
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性带来很大压力。同时很多应用共享数据的锁定状态,只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。先不挂起线程,等一会儿。
如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,让后面请求锁的线程稍等一会,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放。为了让线程等待,我们只需让线程执行一个忙循环(自旋)。
自旋等待本身虽然避免了线程切换的开销,但它要占用处理器时间。所以如果锁被占用的时间很短,自旋等待的效果就非常好;如果时间很长,那么自旋的线程只会白白消耗处理器的资源。所以自旋等待的时间要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,那就应该使用传统的方式挂起线程了。
自旋的时间不固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机也会越来越聪明。
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但被检测到不可能存在共享数据竞争的锁进行消除。主要根据逃逸分析。
程序员怎么会在明知道不存在数据竞争的情况下使用同步呢?很多不是程序员自己加入的。
原则上,同步块的作用范围要尽量小。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作在循环体内,频繁地进行互斥同步操作也会导致不必要的性能损耗。
锁粗化就是增大锁的作用域。
在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。即在无竞争的情况下,把整个同步都消除掉。这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。
参考:《深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第 2 版)》
如果觉得文章对你有点帮助,请微信搜索并关注「 冰河技术 」微信公众号,跟冰河学习高并发编程技术。
最后,附上并发编程需要掌握的核心技能知识图,祝大家在学习并发编程时,少走弯路。
记住:你比别人强的地方,不是你做过多少年的 CRUD 工作,而是你比别人掌握了更多深入的技能。不要总停留在 CRUD 的表面工作,理解并掌握底层原理并熟悉源码实现,并形成自己的抽象思维能力,做到灵活运用,才是你突破瓶颈,脱颖而出的重要方向!
你在刷抖音,玩游戏的时候,别人都在这里学习,成长,提升,人与人最大的差距其实就是思维。你可能不信,优秀的人,总是在一起。
领取专属 10元无门槛券
私享最新 技术干货