最近,一直有小伙伴让我整理下关于JVM的知识,通过十几天的收集与整理,第一版算是整理出来了。但愿对你们有所帮助。java
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_1 iconst_1 iadd istore_0
两条iconst_1指令连续地把两个常量1压入栈中,iadd指令把栈顶的两个值出栈相加,把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。
最典型的是x86的地址指令集,依赖寄存器工做。
计算“1+1=2”,基于寄存器的指令集是这样的:
mov eax, 1 add 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版)》
若是以为文章对你有点帮助,请微信搜索并关注「 冰河技术 」微信公众号,跟冰河学习高并发编程技术。
最后,附上并发编程须要掌握的核心技能知识图,祝你们在学习并发编程时,少走弯路。