Java 虚拟机(一)基础理论
进阶高级程序员的必经之路,可能平时写代码用不大上,但是在一定场景下调节jvm参数能够进行系统的调优。就像存储系统一样,要么对支持小文件优化的好点,要么对支持大文件优化的好点,很难两者都兼得。jvm也一样,不同场景下,可能有不同的运行参数。
如果要自己设计一个jvm,需要考虑哪些方面呢?
如何成为一个“机”(虚拟机)?
JVM的导出宣传的口号“一次编译到处运行”,那考虑操作系统的兼容性,应该是一大部分工作,如何设计出公共的抽象的接口,在linux下有POSIX(Portable Operating System Interface)接口,虽然也不是所有linux都是依照这规范设计。对应的JVM应该有一套标准,底层的虚拟机换了,上面跑的程序代码应该是透明的。那如果自己定义虚拟机,首先要有一套specification。如何定义这specification呢?最简单的就是按照c++的ACE的那套接口,这只是有了接口,能做到“代码可以到处编译”,还是有编译的过程,那如何先编译成中间的二进制文件,能够到处运行呢?
既然都叫虚拟机了,那就在参考Linux操作系统的样子,做个虚拟的操作系统吧,linux的内核有驱动、文件系统、内存管理、进行调度、进程通信等模块,可以参考linux操作系统及内核。JVM不需要的就是各种驱动了,也不需要啥工作量直接用上面的各种系统调用就好了。
文件系统其实还好,对宿主的操作系统进行封装,都有目录、文件的概念。
进行通信,这也是照葫芦画瓢,提供锁、信号量、condition_wait方式就可以了。
进程调度也是一样,与linux操作系统上没有特别大的差别。
程序运行时候的内存模型,一样,参考linux的即可。需要有栈、堆、存放静态变量和全局变量的数据区、存放可执行文件的区域。最最不同的就是这内存管理了,自动的垃圾回收。
如何实现垃圾回收?
首先,基本的内存模型也是按照linux的套路,分为栈和堆,堆是放大对象也是回收的关键,栈是临时的放一些不是new出来的东西,在退出作用域直接释放掉。
Java中都是对象,暂时只考虑针对Java的虚拟的设计,JVM也可以是很多语言的虚拟机如最近火的Scale。如果都是对象,那回收的单位都是对象。
回收大概分为两步,分析那些内存(对象需要回收),到了回收的时候,发个任务一次批量回收。这两步中会涉及到以下问题:
- 分析待回收对象算法
- 回收操作的触发算法
- 如何回收(单线程or并发)
分析回收对象的算法,这算法其实还好实现,因为JVM管理了所有的对象,对象创建后一般有引用关系,那么可以从程序运行开始,记录所有的static的对象,和main方法中的对象,这些对象作为根对象,像一颗树一样向上生长,如果一个对象没有任何的对象对他引用,那就是个孤零零的东西,用户也不会访问到,也自然没什么用了,就可以标记下,等待删除了。
回收操作的触发算法,可以定时周期,可以是使用到了一定阈值的,另外也可以考虑操作系统的繁忙情况,对象创建的速度,创建的加速度等。然后就是各种对各种方式的综合应用。回收的算法也可以像锁一样,有对个算法逐渐升级的过程,甚至在复杂点有个状态转换的过程,而且根据对象的情况可以使用不同的回收算法,例如,对于新创建的,创建速度非常快的对象,要以一个短的周期来检查,对于活了长时间的对象,可以按照周期短一点,然后
搞定上面的问题后,需要在考虑优化的事情,因为回收的是对象,那可以用对象的什么特性来进行优化? 从对象的角度考虑,对实际情况做一些假设:
- 分析待回收的对象不一定需要一次全分析完,可以对对象进行统计分析,如果对象生成的特别快,那么,可以对这种对象要严格的看护,加强分析频率,否则内存增长的会很快。
- 一般的对象生命周期都比较短,如果一个对象已经存在挺长时间了,例如已经活了一分钟了,那么这个对象下一秒可以被回收的概率会很小,这个时候虚拟机可以对这类对象检查的频率降低些。
- 一般的对象体积都比较先如果有大对象,那么对大对象分析就要频繁点。
- 如果一个对象刚刚创建,那么创建后被立刻回收的可能性也不是很大,可以延迟检查下,但这延迟的时间有限。
- 如果一个对象被其他对象引用的次数很多,那么这个对象被立刻回收的概率也很小。
其他的,从资源使用的角度来看,cpu主要是操作内存,速度很快,不用开很多线程,而且操作内存,线程越多,还会有竞争。所以尽可能单线程工作,而对内存的使用看,最重要的是不要有碎片,碎片越多,利用率越低,分配的时候查找可用的内存速度越慢。
内存分配的优化算法可以参考google的tcmalloc,可以按照内存的大小粒度,对小的内存空间和大的内存空间分别管理,对小的空间可以预先分配好放在那,用的时候直接拿去就行,不用每次都重新申请。
自己能想到的就是上面的部分,下面看下真正JVM是如何设计的。
JVM是什么
直接参考JVM的规范对JVM的概述:The Java Virtual Machine
The Java Virtual Machine is an abstract computing machine. Like a real computing machine, it has an instruction set and manipulates various memory areas at run time. It is reasonably common to implement a programming language using a virtual machine;
The Java Virtual Machine knows nothing of the Java programming language, only of a particular binary format, the class file format. A class file contains Java Virtual Machine instructions (or bytecodes) and a symbol table, as well as other ancillary information.
可见JVM就是可以看作个virtual machine,与linux相比,linux执行编译好的c的文件,而JVM执行的是编译好的class文件而已。而且,与linux操作系统,的结构,程序运行逻辑基本一样。
真正的虚拟机的设计
JVM的结构,Specification Java SE 7 Edition。
主要是三个部分组成,作为一个JVM的标准,还包括了很多内容,如class文件格式,基础的数据类型等,参考The Structure of the Java Virtual Machine,其中,JVM的原始数据类型与java的是一样的。
很多时候,从大的结构上看JVM可以大概分成三个部分,如上图,Class Loader、JVM Memory、Execution Engine。
1. classloader
The Java Classloader is a part of the Java Runtime Environment that dynamically loads Java classes into the Java Virtual Machine.
这里要注意dynamically这个词,动态加载的,一是减少内存的使用,而是能够加快加载速度。双亲委派模型
2. 执行引擎
直接引用Understanding JVM Internals中对execution engine的表述:
The bytecode that is assigned to the runtime data areas in the JVM via class loader is executed by the execution engine. The execution engine reads the Java Bytecode in the unit of instruction. It is like a CPU executing the machine command one by one. Each command of the bytecode consists of a 1-byte OpCode and additional Operand. The execution engine gets one OpCode and execute task with the Operand, and then executes the next OpCode.
But the Java Bytecode is written in a language that a human can understand, rather than in the language that the machine directly executes. Therefore, the execution engine must change the bytecode to the language that can be executed by the machine in the JVM. The bytecode can be changed to the suitable language in one of two ways.
- Interpreter: Reads, interprets and executes the bytecode instructions one by one. As it interprets and executes instructions one by one, it can quickly interpret one bytecode, but slowly executes the interpreted result. This is the disadvantage of the interpret language. The ‘language’ called Bytecode basically runs like an interpreter.
- JIT (Just-In-Time) compiler: The JIT compiler has been introduced to compensate for the disadvantages of the interpreter. The execution engine runs as an interpreter first, and at the appropriate time, the JIT compiler compiles the entire bytecode to change it to native code. After that, the execution engine no longer interprets the method, but directly executes using native code. Execution in native code is much faster than interpreting instructions one by one. The compiled code can be executed quickly since the native code is stored in the cache.
执行引擎的作用是把java的bytecode转成对应平台的native code,然后执行。转换主要是采用JIT的方式,这个Just-In-Time的翻译并不是“实时”,而是“及时,时机恰到好处”,而达到执行启动的速度又快,执行速度又快的目的。
JIT本质是解释执行,但是又不是完全的解释执行,与一半的脚本语言比,解释的不是源代码,而是源代码生成的类的字节文件。在server模式下,会进行分析,对部分热代码进行编译,生成对应的机器码,从而让执行效率更高。在java9中可以通过使用aot编译器,直接把字节码编译成机器码,虽然多了一部“编译”,但可以提高些效率。
PS:考到了一个比喻,解释执行和编译执行有何区别-“一个是声传译,一个是放录音”。
PS:Write once, run anywhere,对于像c和c++这样的语言,在兼容不同操作系统的时候,会修改源代码,如果不想修改源代码,就需要使用一些中间的库,如c++的ACE,而java并不需要,因为jvm的规范代替了中间库的作用,java编译后的class文件就是遵循这样的规范,兼容的工作交给jvm来处理。
3.runtime data area
The Java Virtual Machine defines various run-time data areas that are used during execution of a program. Some of these data areas are created on Java Virtual Machine start-up and are destroyed only when the Java Virtual Machine exits. Other data areas are per thread. Per-thread data areas are created when a thread is created and destroyed when the thread exits.
规范Run-Time Data Areas中的说明。
The pc Register
线程独占,指示字节码运行到哪里了,这和linux中的c程序概念差不多。
- Java Virtual Machine Stacks
这里为什么不直接叫stack?因为这stack分了很多种,比如还有下面的Native Method Stacks。
这个stack是每个线程私有的,每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程,与inux的c程序中概念差不多。可能抛出的异常:
- If the computation in a thread requires a larger Java Virtual Machine stack than is permitted, the Java Virtual Machine throws a StackOverflowError.
- If Java Virtual Machine stacks can be dynamically expanded, and expansion is attempted but insufficient memory can be made available to effect the expansion, or if insufficient memory can be made available to create the initial Java Virtual Machine stack for a new thread, the Java Virtual Machine throws an OutOfMemoryError.
Heap
所有线程共享的,进行对象内存的分配均需要进行加锁,这也是new开销比较大的原因。
Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间,这块空间又称为TLAB,但TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。这点设计与tcmalloc中的设计很像,tcmalloc中有对单独线程的cache的区域,这样线程在申请内存的时候就不用加锁了,分配的效率就会高很多。
而且,这种对于小内存与大内存分别处理的思路,tcmalloc中也采用了,对小内存预先分配,用一定的“内存泄漏”获取更高的分配性能。
可能的异常:
If a computation requires more heap than can be made available by the automatic storage management system, the Java Virtual Machine throws an OutOfMemoryError.
Method Area
The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. The method area is analogous to the storage area for compiled code of a conventional language or analogous to the “text” segment in an operating system process. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization.
If memory in the method area cannot be made available to satisfy an allocation request, the Java Virtual Machine throws an OutOfMemoryError.
线程共享的,类信息、常量、静态变量、存储编译后的类的字节码,还包括下面的Run-Time Constant Pool。
这个区域在Hotspot中也称为永久代“Permannent Generation”,只是一个实现JVM的方法区的一种方式,而不是什么标准。这样有一个问题,这个永久代也是需要配置内存使用上限的,有可能有内存溢出抛出异常的问题,而这部分其实应该是最优先级保证的内存,其他的部分虚拟机实现的时候,对此内存限制是进程使用的内存的上限,Hotspot也有规划,放到Native Memory中。
Run-Time Constant Pool
A run-time constant pool is a per-class or per-interface run-time representation of the constant_pool table in a class file (§4.4). It contains several kinds of constants, ranging from numeric literals known at compile-time to method and field references that must be resolved at run-time. The run-time constant pool serves a function similar to that of a symbol table for a conventional programming language, although it contains a wider range of data than a typical symbol table.
主要是编译期间虚拟机使用的,感觉类似c中编译后的.o临时文件,包括一些符号信息。
Native Method Stacks
虚拟机使用到的本地方法服务。在调用封装的各种系统调用的时候,需要把调用的参数等信息临时放入栈。
直接内存
上述是JVM的规范中说明的六中运行时数据区,图中有5个是因为运行时常量池是在方法区中分配的。
还有个直接内存的概念,这个不是JVM规范的一部分。是为了NIO方法调用native方法时候,native方法使用的内存,为了优化不在拷贝到堆中可以直接访问的内存。虚拟机为了IO特殊优化的,可见内存拷贝也是造成性能的一个很大的问题。
垃圾回收
GC概念
GC是后台的守护进程。它的特别之处是它是一个低优先级进程,但是可以根据内存的使用情况动态的调整他的优先级。因此,它是在内存中低到一定限度时才会自动运行,从而实现对内存的回收。这就是垃圾回收的时间不确定的原因。
因为GC也是进程,也要消耗CPU等资源,如果GC执行过于频繁会对java的程序的执行产生较大的影响(java解释器本来就不快),因此JVM的设计者们选着了不定期的gc。
- 引用计数法
简单但速度很慢。缺陷是:不能处理循环引用的情况。
PS:弱引用的使用场景?不想删除掉Map中的对象,一个对象一个强引用一个弱引用, 当外部强引用不存在时候,Map的弱引用会自动的被回收
- 根搜索法(GC Root Tracing)
从根部节点搜索一遍对于没有引用的对象,就是应该删除的对象,用做根的有:
- 栈帧中引用的对象
- 类的静态属性引用的对象
- JNI方法中引用的对象
- 常量引用的对象
垃圾清除方法
- 标记 - 清除算法 (mark and sweep)
速度较快,占用空间少,标记清除后会产生大量的碎片。有大碎片分配大内存的时候找不到连续空间, 会提前出发一次GC,浪费cpu
- 复制算法(copying)
效率低浪费一半的内存,需要的空间大,优点,不会产生碎片。IBM研究98%对象死的非常快,下次复制的时候,98%的都要回收,所以有了改良的复制算法,Hotspot中Eden与Survive比例是8:1,一个Eden两个Survive,Survive交替的用来等待复制,复制的时候,把Eden和Survive中活着的对象,全部复制到另外一个Survive中,这样会浪费10%的Eden的内存。
- 标记整理(压缩)(Mark-compact)
针对老年代的,向一端整理,可以移动连续的多个对象,一般老年代死的少,所以出现的需要的回收的空间少(移动次数少)
垃圾收集器
垃圾回收器可以按照以下几个维度来考虑,每个回收器都有各自的优势,并不是哪一种一定好,而是在特定场景下根据需求选择特定的回收器与其参数,垃圾收集器分析的维度有:
- 基础算法
- 是否为并发(concurrent),与用户线程并发交替地执行
- 是否为并行(parallel),多个回收线程是否能并行执行
- 吞吐量,吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)
- 是否会stop the world
- stop the world的时间
- 适用的分代内存
- 适合的场景
- 稳定性
上面几个维度相互之间有关联,而且关联的还是比较强,基础当然是算法的不同。来个总体的图,参考深入理解JVM(5) : Java垃圾收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。
Serial / Serial Old 收集器
特性:
- 最基础的,相对成熟稳定
- 单线程,收集时候需要stop the world(所有的cpu都要停止)
- 简单效率高(Redis与Ngnix也是单线程)
- 场景,桌面场景中的默认新生代复制算法收集器。
- Serial新生代,复制算法
- Serial Old 老年代,使用标记整理算法
- Serial Old为CMS的后备收集器
ParNew 收集器
特性:
- Serial的多线程版,默认与cpu数目一致,一个cpu一个收集线程
- 在单核(甚至双核)cpu上不如Serial
- 除了Serial收集器外,目前只有它能与CMS收集器配合工作(导致这收集器用处很广)
- 适用于多核心CPU,Server模式下,新生代首选收集器
Parallel Scavenge / Parallel Old收集器 收集器
Parallel Scavenge特性:
- “吞吐量优先”收集器
- 适用于年轻代,复制算法,多线程回收,与ParNew很像,只是更关心吞吐量,而不是停顿时间
- Java Server模式下的默认收集器。
Parallel Old 收集器特性:
- Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法
- 适合在多核心CPU的场景,对吞吐量有要求的场景
CMS(Concurrent Mark Sweep) 收集器
特性:
- 最短停顿优先收集器
- 应用场景,互联网和B/S架构
- 标记-清除算法
- CMS是CPU敏感的,默认启动的回收线程数是(CPU数量+3)/4,如果CPU核数过少,将会降低吞吐率
参数:
- -XX:+UseCMSCompactAtFullCollection参数(默认开启),用于解决内存碎片问题,在full gc时整理内存,但这会stop the world,导致停顿时间变长。
- -XX:+CMSFullGCsBeforeCompaction,为了解决上述的停顿时间长,可以几次full gc后整理一次
- -XX:ParallelCMSThreads,可以直接指定并发的线程数目
- -XX:CMSInitiatingOccupancyFraction参数调整内存比例,触发使用收集器,在JDK1.6后为92%。这参数存在意义是,由于是与用户线程并发的,需要考虑用户线程的运行情况需要预留一部分内存,所以要提前收集。
G1 收集器
G1收集器设计的目标是用来替换CMS收集器的,与CMS相比,有如下特性:
- G1基于“标记-整理”算法不会产生内存碎片
- G1能充分利用多CPU、多核环境下的硬件优势,追求降低停顿时间
- 能建立可预测的停顿时间模型,能让使用户明确指定M毫秒的时间段内,gc的时间不超过N毫秒
- 没有严格的分代内存的物理界限,而是有很多大小不同的Region(与Tcmalloc很像)
- 对于存活时间短的大对象有特殊优化,存放在Humongous区
- 优化了根对象的扫描机制,分析对象间关系,快速找到根对象扫描
PS和PS Old也有个参数,”MaxGCPauseMilllis”用于设置最大的GC的停顿时间,但根据《深入理解Java虚拟机》一书中所说的,这个时间可控性较差,很可能超过这个时间。而G1的时间,相对稳定。