jvm的学习

一、jvm的位置

JVM是运行在操作系统之上的,它与硬件没有直接的交互

二、jvmt体系结构

①程序计数器

(1)程序计数器是一块较小的内存空间,是当前线程所执行的字节码的行号指示器,字节码解释器工作时是通过改变这个计数器的值来选取下一条需要执行的字节码(包括分支、循环、跳转、异常处理、线程恢复)

(2)java虚拟机多线程是通过线程轮流切换分配处理器执行时间,为了多线程切换后能恢复到正确的额执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

(3)如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)

解释:JVM执行native方法,计数器为空(undefined),又怎么继续执行Java代码的问题?

问题:我们知道,程序计数器用来存放字节码指令地址;通过这个地址,虚拟机就能知道执行到哪里,以及怎么往下执行,可调用native方法,值就变成空了,那么机器不就直接崩溃了吗?

解释:参考C++理解是:当线程中调用native方法的时候,则重新启动一个新的线程,那么新的线程的计数器为空则不会影响当前线程的计数器,相互独立。

问题:如果是新启动的一个线程,那么不会因为线程异步问题,无法控制执行顺序吗?

解释:当前线程应当会被阻塞,知道另外一个线程执行结束。例如:通过死循环来控制阻塞(当然死循环效率太低,这里只是一个例子)

(4)此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域

②java vm栈(java虚拟机栈)

(1)栈的概念

“栈”-也是线程私有的一块内存,生命周期和线程一样。每个方法在执行的同时都会创建一个栈帧(StackFrame[1])用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程

局部变量表,存放了可知的基本数据类型(boolean、byte、char、short、int、float、long、double),对象引用和returnAddress(指向了一条字节码指令的地址),局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小

(2)栈的两种异常

1、StackOverflowError

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常

解释:每次方法调用都会有一个栈帧压入虚拟机栈,操作系统给JVM分配的内存是有限的,JVM分配给“虚拟机栈”的内存是有限的。如果方法调用过多,导致虚拟机栈满了就会溢出。这里栈深度就是指栈帧的数量。

栈溢出的一个实例

循环递归调用:(原理)每次调用一次方法就会创建一个栈帧压入栈,达到栈的深度就会报栈异常

2、OutOfMemoryError

如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常

③heap堆(Java7之前)

一个JVM实例只存在一个堆内存,被所有线程共享,堆内存的大小是可以调节的。java堆是垃圾回收器管理的主要区域,因此很多时候也被称作“GC堆”。类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行。

堆内存逻辑上分为三部分:新生+养老+永久

(1)新生区

新生区是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。新生区又分为两部分:伊甸区(Edenspace)和幸存者区(Survivorpace),所有的类都是在伊甸区被new出来的。幸存区有两个:0区(Survivor0space)和1区(Survivor1space)。当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。然后将伊甸园中的剩余对象移动到幸存0区.若幸存0区也满了,再对该区进行垃圾回收,然后移动到1区。那如果1区也满了呢?再移动到养老区。若养老区也满了,那么这个时候将产生MajorGC(FullGC),进行养老区的内存清理。若养老区执行了FullGC之后发现依然无法进行对象的保存,就会产生OOM异常“OutOfMemoryError”。

如果出现java.lang.OutOfMemoryError:Javaheapspace异常,说明Java虚拟机的堆内存不够。原因有二:

(1)Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。

(2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

(2)Java7的堆构成

(3)Java8的堆构成

JDK1.8之后将最初的永久代取消了,由元空间取代

(4)直接内存

直接内存(DirectMemory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。在JDK1.4中新加入了NIO(NewInput/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

④方法区

1:方法区是线程共享的,通常用来保存装载的类的元结构信息。比如:运行时常量池+静态变量+常量+类信息+即时编译器编译后的代码等数据。

2:通常和永久区关联在一起(Java7之前),但具体的跟JVM的实现和版本有关

详细介绍:https://blog.csdn.net/SunshineLPL/article/details/78318709?locationNum=9&fps=1

3、方法区的常量池

Java中的常量池(字符串常量池、class常量池和运行时常量池)

参考:https://blog.csdn.net/zm13007310400/article/details/77534349

⑤、类装载器ClassLoader

(1)类装载器ClassLoader

负责加载class文件,class文件在文件开头有特定的文件标示,并且ClassLoader只负责class文件的加

载,至于它是否可以运行,则由ExecutionEngine决定

(2)类装载器ClassLoader2

•虚拟机自带的加载器

•启动类加载器(Bootstrap)C++

•扩展类加载器(Extesion)Java

•应用程序类加载器(App)Java

也叫系统类加载器,加载当前应用的classpath的所有类

•用户自定义加载器Java.lang.ClassLoader的子类,用户可以定制类的加载方式

(3)类装载器ClassLoader3

(4)Code案例

启动类加载器:扩展类加载器:应用类加载器:

运行结果:

特别说明:扩展类加载器

把自己做的类打成jar包,放入到jre的目录

扩展类文件夹下,java虚拟机启动的时候就会把扩展类加入的类加载机中,直接调用即可

•sun.misc.Launcher

它是一个java虚拟机的入口应用

某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父

类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加

载。

重要概念:双亲委托机制+沙箱机制(防止恶意代码对java的破坏)

解释:双亲委托,加载类的时候先通过启动类加载器和扩展类加载器加载,若果以上两者双亲都没有加载成功,再使用系统加载器,沙箱意思是启动类加载器和扩展类加载器中已经定义的方法不会在系统加载器中进行用户定义的同名方法。

⑥NativeInterface本地接口

Java语言本身不能对操作系统底层进行访问和操作,但是可以通过JNI接口调用其他语言来实现对底层的访问。

本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序,Java诞生的时候是C/C++横行的时候,要想立足,必须有调用C/C++程序,于是就在内存中专门开辟了一块区域处理标记为Native的代码,

它的具体做法是NativeMethodStack中登记Native方法,在ExecutionEngine执行时加载Nativelibraries。

目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用WebService等等,不多做介绍。

(1)NativeMethodStack

它的具体做法是NativeMethodStack中登记native方法,在ExecutionEngine执行时加载本地方法库。

⑦pc寄存器

每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。

通俗解释:就是这份方法执行完了,要调用下一个方法,技术应该去找谁。

三、堆的调优

①堆内存调优简介01

publicstaticvoidmain(String[]args){

longmaxMemory=Runtime.getRuntime().maxMemory();//返回Java虚拟机试图使用的最大内存量。

longtotalMemory=Runtime.getRuntime().totalMemory();//返回Java虚拟机中的内存总量。

System.out.println("MAX_MEMORY="+maxMemory+"(字节)、"+(maxMemory/(double)1024/1024)+"MB");

System.out.println("TOTAL_MEMORY="+totalMemory+"(字节)、"+(totalMemory/(double)1024/1024)+"MB");

}

②模拟堆内存满的情况

Stringstr="www.atguigu.com";

while(true)

{

str+=str+newRandom().nextInt(88888888)+newRandom().nextInt(999999999);

}

VM参数:-Xms8m-Xmx8m-XX:+PrintGCDetails

  1. 1、GC日志打印信息:

    -XX:+PrintGCTimeStamps输出格式:

    289.556:[GC[PSYoungGen:314113K->15937K(300928K)]405513K->107901K(407680K),0.0178568secs][Times:user=0.06sys=0.00,real=0.01secs]

    293.271:[GC[PSYoungGen:300865K->6577K(310720K)]392829K->108873K(417472K),0.0176464secs][Times:user=0.06sys=0.00,real=0.01secs]

    详解:

    293.271是从jvm启动直到垃圾收集发生所经历的时间,GC表示这是一次MinorGC(新生代垃圾收集);[PSYoungGen:300865K->6577K(310720K)]提供了新生代空间的信息,PSYoungGen,表示新生代使用的是多线程垃圾收集器ParallelScavenge。300865K表示垃圾收集之前新生代占用空间,6577K表示垃圾收集之后新生代的空间。新生代又细分为一个Eden区和两个Survivor区,MinorGC之后Eden区为空,6577K就是Survivor占用的空间。

    括号里的310720K表示整个年轻代的大小。

    392829K->108873K(417472K),表示垃圾收集之前(392829K)与之后(108873K)Java堆的大小(总堆417472K,堆大小包括新生代和年老代)

    由新生代和Java堆占用大小可以算出年老代占用空间,如,Java堆大小417472K,新生代大小310720K那么年老代占用空间是417472K-310720K=106752k;垃圾收集之前老年代占用的空间为392829K-300865K=91964k垃圾收集之后老年代占用空间108873K-6577K=102296k.

    0.0176464secs表示垃圾收集过程所消耗的时间。

    [Times:user=0.06sys=0.00,real=0.01secs]提供cpu使用及时间消耗,user是用户模式垃圾收集消耗的cpu时间,实例中垃圾收集器消耗了0.06秒用户态cpu时间,sys是消耗系统态cpu时间,real是指垃圾收集器消耗的实际时间。

  2. 2、-XX:+PrintGCDetails输出格式:

    293.289:[FullGC[PSYoungGen:6577K->0K(310720K)]

    [PSOldGen:102295K->102198K(134208K)]108873K->102198K(444928K)

    [PSPermGen:59082K->58479K(104192K)],0.3332354secs]

    [Times:user=0.33sys=0.00,real=0.33secs]

    说明:

    FullGC表示执行全局垃圾回收

    [PSYoungGen:6577K->0K(310720K)]提供新生代空间信息,解释同上

    [PSOldGen:102295K->102198K(134208K)]提供了年老代空间信息;

    108873K->102198K(444928K)整个堆空间信息;

    [PSPermGen:59082K->58479K(104192K)]提供了持久代空间信息;

  3.  


 

来源:https://jingyan.baidu.com/article/3ea51489c045d852e61bbaab.html

③堆内存分析

(1)安装

(2)使用工具分析

-XX:+HeapDumpOnOutOfMemoryError

OOM时导出堆到hprof文件。

启动参数:

-Xms1m-Xmx8m-XX:+HeapDumpOnOutOfMemoryError

-XX:+HeapDumpOnOutOfMemoryError

OOM时导出堆到hprof文件。

四、调优实战

除了程序计数器外其它运行时区域都有发生OutOfMemoryError异常的可能,因为程序计数器不受虚拟机内存管理。

①java堆的溢出

配置:-Xms20m-Xmx20m-XX:+HeapDumpOnOutOfMemoryError

出现办法:大量的创建对象

②虚拟机栈和本地方法栈溢出

配置:-Xss128k

出现办法:在单线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。

③方法区和运行时常量池溢出

配置:-XX:PermSize=10M-XX:MaxPermSize=10M

JDK7将String常量池从Perm区移动到了JavaHeap区。在JDK1.6中,intern方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中的实例。但是在JDK1.7以后,String.intern()方法不会在复制实例,只是在常量池中记录首次出现的实例引用。下面来看一些具体例子。

案例一:

 
  1. Stringstr1=newString("计算机")+newString("软件");

  2. System.out.println(str1.intern()==str1);

  3. Stringstr2=newString("ja")+newString("va");

  4. System.out.println(str2.intern()==str2);

输出结果:

JDK1.6falsefalse
JDK1.7truefalse

分析:在JDK1.6中,intern方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中的实例,而由newString()创建的字符串实例在Java堆中所以不是同一个引用,都返回false。但是在JDK1.7以后,String.intern()方法不会在复制实例,只是在常量池中记录首次出现的实例引用,因此str1.intern()=str1,但是Java字符串是Java中的关键字,早已创建,所以str2.intern()!=str2。当然如果用StringBuilder创建字符串效果也是一样的。

 
  1. Stringstr1=newStringBuilder("计算机").append("软件").toString();

  2. System.out.println(str1.intern()==str1);

  3. Stringstr2=newStringBuilder("ja").append("va").toString();

  4. System.out.println(str2.intern()==str2);

---------------------本文来自ZLL_CSDN2018的CSDN博客,全文地址请点击:https://blog.csdn.net/smiling_Z/article/details/82686681?utm_source=copy

出现办法:使用动态代理创建大量的类

④本机直接内存溢出

DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与java堆最大值(-Xmx指定)一样。

配置:-Xmx20M

五、垃圾回收器与内存分配策略

①概述

Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由JIT编译器进行一些优化,但在本章基于概念模型的讨论中,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存,本章后续讨论中的“内存”分配与回收也仅指这一部分内存。

②对象已死

是指不可能再被任何途径使用的对象

(1)可达性分析算法

在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中,都是称通过可达性分析(ReachabilityAnalysis)来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GCRoots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(ReferenceChain),当一个对象到GCRoots没有任何引用链相连(用图论的话来说,就是从GCRoots到这个对象不可达)时,则证明此对象是不可用的。如图3-1所示,对象object5、object6、object7虽然互相有关联,但是它们到GCRoots是不可达的,所以它们将会被判定为是可回收的对象。图3-1可达性分析算法判定对象是否可回收在Java语言中,为GCRoots的对象包括下面几种:

虚拟机栈(栈帧中的本地变量表)中引用的对象。

方法区中类静态属性引用的对象。方法区中常量引用的对象。

本地方法栈中JNI(即一般说的Native方法)引用的对象

(2)引用

Java对引用的概念进行了扩充,将引用分为强引用(StrongReference)、软引用(SoftReference)、弱引用(WeakReference)、虚引用(PhantomReference)4种,这4种引用强度依次逐渐减弱。

强引用就是指在程序代码之中普遍存在的,类似“Objectobj=newObject()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将
要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供SoftReference类来实现软引用。
弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的
对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。
虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引
用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用

(3)对象的回收过程

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处
于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GCRoots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。从代码清单3-2中我们可以看到一个对象的finalize()被执行,但是它仍然可以存活。

③回收方法区

主要回收两部分:废弃常量和无用的类

常量池中的类(接口),方法,字段的字面符号没有被引用就会被回收

类回购比较苛刻:(1)该类的所有实例被回收

(2)加载该类的ClassLoader被回收

(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

④垃圾回收算法

(1)标记清除算法(参考上文)

不足:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

(2)复制收集算法(针对于新生区)

为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容
量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着
的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是
对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指
针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原
来的一半,未免太高了一点。复制算法的执行过程如图3-3所示。
图3-3复制算法示意图

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小Survivor空间,每次使用Eden和其中一块Survivor[1]。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认EdenSurvivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存当Surviv空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(HandlePromotion)。内存的分配担保就好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。关于对新生代进行分配担保的内容

(3)标记-整理算法(针对于老年代)

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的示意图如
图3-4所示。

(4)对以上算法在jvm的用法总结(分代收集算法)

当前商业虚拟机的垃圾收集都采用“分代收集”(GenerationalCollection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

⑤hotspot的gc算法实现

(1)JVMSafepoint安全点

http://www.bubuko.com/infodetail-2127087.html

⑥GC日志解析

每一种收集器的日志形式都是由它们自身的实现所决定的,换而言之,每个收集器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个收集器的日志都维持一定的共性,例如以下两段典型的GC日志:
33.125:[GC[DefNew:3324K->152K(3712K),0.0025925secs]3324K->152K(11904K),0.0031680secs]
100.667:[FullGC[Tenured:0K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007secs][Times:user=0.01sys=0.00,real=0.02secs]
最前面的数字“33.125:”和“100.667:”代表了GC发生的时间,这个数字的含义是从Java虚拟机启动以来经过的秒数。
GC日志开头的“[GC”和“[FullGC”说明了这次垃圾收集的停顿类型,而不是用来区分新生代GC还是老年代GC的。如果有“Full”,说明这次GC是发生了Stop-The-World的,例如下面这段新生代收集器ParNew的日志也会出现“[FullGC”(这一般是因为出现了分配担保失败之类的问题,所以才导致STW)。如果是调用System.gc()方法所触发的收集,那么在这里将显示“[FullGC(System)”。
[FullGC283.736:[ParNew:261599K->261599K(261952K),0.0000288secs]
接下来的“[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的,例如上面样例所使用的Serial收集器中的新生代名为“DefaultNewGeneration”,所以显示的是“[DefNew”。如果是ParNew收集器,新生代名称就会变为“[ParNew”,意为“ParallelNewGeneration”。如果采用ParallelScavenge收集器,那它配套的新生代称为“PSYoungGen”,老年代和永久代同理,名称也是由收集器决定的。后面方括号内部的“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)”。而在方括号之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”。再往后,“0.0025925secs”表示该内存区域GC所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如“[Times:user=0.01sys=0.00,real=0.02secs]”,这里面的user、sys和real与Linux的time命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU事件和操作从开始到结束所经过的墙钟时间(WallClockTime)。CPU时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以读者看到user或sys时间超过real时间是完全正常的。

⑦垃圾收集器参数总结

⑧MinorGC和FULLGC的不同

新生代GC(MinorGC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝
生夕灭的特性,所以MinorGC非常频繁,一般回收速度也比较快。
老年代GC(MajorGC/FullGC):指发生在老年代的GC,出现了MajorGC,经常会伴
随至少一次的MinorGC(但非绝对的,在ParallelScavenge收集器的收集策略里就有直接进行
MajorGC的策略选择过程)。MajorGC的速度一般会比MinorGC慢10倍以上。

代码清单3-5新生代MinorGC
privatestaticfinalint_1MB=1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails
-XX:SurvivorRatio=8
*/
publicstaticvoidtestAllocation(){
byte[]allocation1,allocation2,allocation3,allocation4;
allocation1=newbyte[2*_1MB];
allocation2=newbyte[2*_1MB];
allocation3=newbyte[2*_1MB];
allocation4=newbyte[4*_1MB];//出现一次MinorGC
}

运行结果:
[GC[DefNew:6651K->148K(9216K),0.0070106secs]6651K->6292K(19456K),
0.0070426secs][Times:user=0.00sys=0.00,real=0.00secs]
Heap
defnewgenerationtotal9216K,used4326K[0x029d0000,0x033d0000,0x033d0000)
edenspace8192K,51%used[0x029d0000,0x02de4828,0x031d0000)
fromspace1024K,14%used[0x032d0000,0x032f5370,0x033d0000)
tospace1024K,0%used[0x031d0000,0x031d0000,0x032d0000)
tenuredgenerationtotal10240K,used6144K[0x033d0000,0x03dd0000,0x03dd0000)
thespace10240K,60%used[0x033d0000,0x039d0030,0x039d0200,0x03dd0000)
compactingpermgentotal12288K,used2114K[0x03dd0000,0x049d0000,0x07dd0000)
thespace12288K,17%used[0x03dd0000,0x03fe0998,0x03fe0a00,0x049d0000)
Nosharedspacesconfigured.

⑨进入老年代的情况

(1)大对象直接进入老年代

导致问题:经常出现大对象容易
导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(复习一下:新生代采用复制算法收集内存)

执行代码清单3-6中的testPretenureSizeThreshold()方法后,我们看到Eden空间几乎没有被使用,而老年代的10MB空间被使用了40%,也就是4MB的allocation对象直接就分配在老年代中,这是因为PretenureSizeThreshold被设置为3MB(就是3145728,这个参数不能像-Xmx之类的参数一样直接写3MB),因此超过3MB的对象都会直接在老年代进行分配。注意PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效,ParallelScavenge收集器不认识这个参数,ParallelScavenge收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑ParNew加CMS的收集器组合。代码清单3-6大对象直接进入老年代
privatestaticfinalint_1MB=1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8
*-XX:PretenureSizeThreshold=3145728
*/
publicstaticvoidtestPretenureSizeThreshold(){
byte[]allocation;
allocation=newbyte[4*_1MB];//直接分配在老年代中
}

运行结果:
Heap
defnewgenerationtotal9216K,used671K[0x029d0000,0x033d0000,0x033d0000)
edenspace8192K,8%used[0x029d0000,0x02a77e98,0x031d0000)
fromspace1024K,0%used[0x031d0000,0x031d0000,0x032d0000)
tospace1024K,0%used[0x032d0000,0x032d0000,0x033d0000)
tenuredgenerationtotal10240K,used4096K[0x033d0000,0x03dd0000,0x03dd0000)
thespace10240K,40%used[0x033d0000,0x037d0010,0x037d0200,0x03dd0000)
compactingpermgentotal12288K,used2107K[0x03dd0000,0x049d0000,0x07dd0000)
thespace12288K,17%used[0x03dd0000,0x03fdefd0,0x03fdf000,0x049d0000)
Nosharedspacesconfigured.

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

进入老年代的对象按年龄计算,可通过设置参数-XX:MaxTenuringThreshold来设置年龄阈值

放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置

(3)动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到
了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,无须等到MaxTenuringThreshold中要求的年龄。

执行代码清单3-8中的testTenuringThreshold2()方法,并设置-XX:
MaxTenuringThreshold=15,会发现运行结果中Survivor的空间占用仍然为0%,而老年代比预期增加了6%,也就是说,allocation1、allocation2对象都直接进入了老年代,而没有等到15岁的临界年龄。因为这两个对象加起来已经到达了512KB,并且它们是同年的,满足同年对象达到Survivor空间的一半规则。我们只要注释掉其中一个对象new操作,就会发现另外一个就不会晋升到老年代中去了

(4)空间分配担保

在JDK6Update24之前的版本中运行测试

在发生MinorGC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有
对象总空间,如果这个条件成立,那么MinorGC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次MinorGC,尽管这次MinorGC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次FullGC。

DK6Update24之后

规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行MinorGC,否则将进行FullGC

六、jdk命令行工具

①jps:虚拟机进程状况工具

JDK的很多小工具的名字都参考了UNIX命令的命名方式,jps(JVMProcessStatusTool)是其中的典型。除了名字像UNIX的ps命令之外,它的功能也和ps命令类似:可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(MainClass,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(LocalVirtualMachineIdentifier,LVMID)。虽然功能比较单一,但它是使用频率最高的JDK命令行工具,因为其他的JDK工具大多需要输入它查询到的LVMID来确定要监控的是哪一个虚拟机进程。对于本地虚拟机进程来说,LVMID与操作系统的进程ID(ProcessIdentifier,PID)是一致的,使用Windows的任务管理器或者UNIX的ps命令也可以查询到虚拟机进程的LVMID,但如果同时启动了多个虚拟机进程,无法根据进程名称定位时,那就只能依赖jps命令显示主类的功能才能区分了。

jps可以通过RMI协议查询开启了RMI服务的远程虚拟机进程状态,hostid为RMI注册表中注册的主机名。jps的其他常用选项见表4-2。

    

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

jstat(JVMStatisticsMonitoringTool)是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程[1]虚拟机进程中的类装载、内存、垃圾集、JIT编译等运行数据,在没有GUI图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具

jstat命令格式为:
jstat[optionvmid[interval[s|ms][count]]]
对于命令格式中的VMID与LVMID需要特别说明一下:如果是本地虚拟机进程,VMID与LVMID是一致的,如果是远程虚拟机进程,那VMID的格式应当是:
[protocol:][//]lvmid[@hostname[:port]/servername]
参数interval和count代表查询间隔和次数,如果省略这两个参数,说明只查询一次。假设需要每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,那命令应当是:
jstat-gc276425020
选项option代表着用户希望查询的虚拟机信息,主要分为3类:类装载、垃圾收集、运行期编译状况,具体选项及作用请参考表4-3中的描述。

代码清单4-1jstat执行样例

D:\Develop\Java\jdk1.6.0_21\bin>jstat-gcutil2764
S0S1EOPYGCYGCTFGCFGCTGCT
0.000.006.2041.4247.20160.10530.4720.577

查询结果表明:这台服务器的新生代Eden区(E,表示Eden)使用了6.2%的空间,两个Survivor区(S0、S1,表示Survivor0、Survivor1)里面都是空的,老年代(O,表示Old)和永久代(P,表示Permanent)则分别使用了41.42%和47.20%的空间。程序运行以来共发生MinorGC(YGC,表示YoungGC)16次,总耗时0.105秒,发生FullGC(FGC,表示FullGC)3次,FullGC总耗时(FGCT,表示FullGCTime)为0.472秒,所有GC总耗时(GCT,表示GCTime)为0.577秒。

③jinfo:java配置信息工具

jinfo(ConfigurationInfoforJava)的作用是实时地查看和调整虚拟机各项参数

jinfo命令格式:
jinfo[option]pid
执行样例:查询CMSInitiatingOccupancyFraction参数值。
C:\>jinfo-flagCMSInitiatingOccupancyFraction1444
-XX:CMSInitiatingOccupancyFraction=85jinfo还可以使用-sysprops选项把虚拟机进程的System.getProperties()的内容打印出来

例如:./jinfo-sysprops16414

③jmap:Java内存映像工具

jmap的作用并不仅仅是为了获取dump文件,它还可以查询finalize执行队列、Java堆和永久代的详细信息,如空间使用率、当前用的是哪种收集器等。

jmap命令格式:

jmap[option]vmid

option选项的合法值与具体含义见表4-4。

C:\Users\IcyFenix>jmap-dump:format=b,file=eclipse.bin3500
DumpingheaptoC:\Users\IcyFenix\eclipse.bin……
Heapdumpfilecreated

④jstack:Java堆栈跟踪工具

jstack(StackTraceforJava)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者等待着什么资源。

jstack命令格式:
jstack[option]vmid
option选项的合法值与具体含义见表4-5。

使用jstack查看线程堆栈(部分结果)
C:\Users\IcyFenix>jstack-l3500
2010-11-1923:11:26
FullthreaddumpJavaHotSpot(TM)64-BitServerVM(17.1-b03mixedmode):
"[ThreadPoolManager]-IdleThread"daemonprio=6tid=0x0000000039dd4000nid=0xf50inObject.wait()[0x000000003c96f000]
java.lang.Thread.State:WAITING(onobjectmonitor)
atjava.lang.Object.wait(NativeMethod)
-waitingon<0x0000000016bdcc60>(aorg.eclipse.equinox.internal.util.impl.tpt.threadpool.Executor)
atjava.lang.Object.wait(Object.java:485)
atorg.eclipse.equinox.internal.util.impl.tpt.threadpool.Executor.run(Executor.java:106)
-locked<0x0000000016bdcc60>(aorg.eclipse.equinox.internal.util.impl.tpt.threadpool.Executor)
Lockedownablesynchronizers:
-None

⑤visualVM调试工具:过合一故障处理工具

(1)功能点

显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)。

监视应用程序的CPU、GC、堆、方法区以及线程的信息(jstat、jstack)。

dump以及分析堆转储快照(jmap、jhat)。

方法级的程序运行性能分析,找出被调用最多、运行时间最长的方法。

离线程序快照:收集程序的运行时配置、线程dump、内存dump等信息建立一个快照,

可以将快照发送开发者处进行Bug反馈。其他plugins的无限的可能性......

(2)位置

JDK_HOME/bin/visualvm/中

(3)使用

1、插件安装-点击“工具”→“插件菜单

2、生成、浏览堆转储快照

在“应用程序”窗口中右键单击应用程序节点,然后选择“堆Dump”。

在“应用程序”窗口中双击应用程序节点以打开应用程序标签,然后在“监视”标签中单击“堆Dump”。

如果需要把dump文件保存或发送出去,要在heapdump节点上右键选择“另存为”菜单,否则当VisualVM关闭时,生成的dump文件会被当做临时文件删除掉。

要打开一个已经存在的dump文件,通过文件菜单中的“装入”功能,选择硬盘上的dump文件即可。

3、分析程序性能(profiler)

先选择“CPU”和“内存”按钮中的一个,然后切换到应用程序中对程序进行操作,VisualVM会记录到这段时间中应用程序执行过的法。如果是CPU分析,将会统计每个方法的执行次数、执行耗时;如果是内存分析,则会统计每个方法关联的对象数以及这些对象所的空间。分析结束后,点击“停止”按钮结束监控过程

注意:

在JDK1.5之后,在Client模式下的虚拟机加入并且自动开启了类共享——这是一个在多虚拟机进程中共享rt.jar中类数据以提高加载速度和节省内存的优化,而根据相关Bug报告的反映VisualVM的Profiler功能可能会因为类共享而导致被监视的应用程序崩溃,所以读者进行Profiling前,最好在被监视程序中使用-Xshare:off参数来关闭类共享优化。

4.BTrace动态日志跟踪

BTrace[3]是一个很“有趣”的VisualVM插件,本身也是可以独立运行的程序。它的作用是在不停止目标程序运行的前提下,通过HotSpot虚拟机的HotSwap技术[4]动态加入原本并不存在的调试代码。这项功能对实际生产中的程序很有意义:经常遇到程序出现问题,但排查错误的一些必要信息,譬如方法参数、返回值等,在开发时并没有打印到日志之中,以至于不得不停掉服务,通过调试增量来加入日志代码以解决问题。当遇到生产环境服务无法随便停止时,缺一两句日志导致排错进行不下去是一件非常郁闷的事情

在VisualVM中安装了BTrace插件后,在应用程序面板中右键点击要调试的程序,会出现“TraceApplication......”菜单,点击将进入BTrace面板。这个面板里面看起来就像一个简单的Java程序开发环境,里面还有一小段Java代码,如图4-16示。

应用实例:

BTrace的用法还有许多,打印调用堆栈、参数、返回值只是最基本的应用,在它的网站上有使用

BTrace进行性能监视、定位连接泄漏和内存泄漏、解决多线程竞争问题等例子,有

插件中心地址:

http://Visualvmjava.net/pluginscenters.html。

官方主页:http://kenai.com/projects/btrace/

HotSwap技术:代码热替换技术,

HotSpot虚拟机允许在不停止运行的情况下,更新已经加载的类的代码。

七、调优案例分析与实战

JVM调优

①集群间同步导致的内存溢出

由于信息传输失败需要重发的可能性,在确认所有注册在GMS(GropMembershipservice)的节点都收到正确的信息前,发送的信息必须保存在内存中。当网络情况不能满足传输要求时,重发数据在内存中不断堆积,很快产生了内存溢出。

②堆外内存溢出错误

(1)从实践经验的角度出发,除了Java堆和永久代之外,我们注意到下面这些区域还会占用较多的内存,这里所有的内存总和受到操作系统进程最大内存的限制。

DirectMemory:可通过-XX:MaxDirectMemorySize调整大小,内存不足时抛出OutOfMemoryError或者OutOfMemoryError:Directbuffermemory。

(2)线程堆栈:可通过-Xss调整大小,内存不足时抛出StackOverflowError(纵向无法分配,即无法分配新的栈帧)或者OutOfMemoryError:unabletocreatenewnativethread(横向无法分配,即无法建立新的线程)。

(3)Socket缓存区:每个Socket连接都Receive和Send两个缓存区,分别占大约37KB和25KB内存,连接多的话这块内存占用也比较可观。如果无法分配,则可能会抛出IOException:Toomanyopenfiles异常。

(4)JNI代码:如果代码中使用JNI调用本地库,那本地库使用的内存也不在堆中。

(5)虚拟机和GC:虚拟机、GC的代码执行也要消耗一定的内存。

③外部命令导致系统缓慢

例如:问题:这是一个来自网络的案例:一个数字校园应用系统,运行在一台4个CPU的Solaris10操作系统上,中间件为GlassFish服务器。系统在做大并发压力测试的时候,发现请求响应时间比较慢,通过操作系统的mpstat工具发现CPU使用率很高,并且系统占用绝大多数的CPU资源的程序并不是应用系统本身。这是个不正常的现象,通常情况下用户应用的CPU占用率应该占主要地位,才能说明系统是正常工作的。通过Solaris10的Dtrace脚本可以查看当前情况下哪些系统调用花费了最多的CPU资源,Dtrace运行后发现最消耗CPU资源的竟然是“fork”系统调用。众所周知,“fork”系统调用是Linux用来产生新进程的,在Java虚拟机中,用户编写的Java代码最多只有线程的概念,不应当有进程的产生。

答案:这是个非常异常的现象。通过本系统的开发人员,最终找到了答案:每个用户请求的处理都需要执行一个外部shell脚本来获得系统的一些信息。执行这个shell脚本是通过Java的Runtime.getRuntime().exec()方法来调用的。这种调用方式可以达到目的,但是它在Java虚拟机中是非常消耗资源的操作,即使外部命令本身能很快执行完毕,频繁调用时创建进程的开销也非常可观。Java虚拟机执行这个命令的过程是:首先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗会很大,不仅是CPU,内存负担也很重。用户根据建议去掉这个Shell脚本执行的语句,改为使用Java的API去获取这些信息后,系统很快恢复了正常。

④服务器jvm进程崩溃

这是一个远端断开连接的异常,通过系统管理员了解到系统最近与一个OA门户做了集成,在MIS系统工作流的待办事项变化时,要通过Web服务通知OA门户系统,把待办事项的变化同步到OA门户之中。通过SoapUI测试了一下同步待办事项的几个Web服务,发现调用后竟然需要长达3分钟才能返回,并且返回结果都是连接中断。由于MIS系统的用户多,待办事项变化很快,为了不被OA系统速度拖累,使用了异步的方式调用Web服务,但由于两边服务速度的完全不对等,时间越长就累积了越多Web服务没有调用完成,导致在等待的线程和Socket连接越来越多,最终在超过虚拟机的承受能力后使得虚拟机进程崩溃。解决方法:通知OA门户方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常。

⑤不恰当的数据结构导致内存占用过大

下面具体分析一下空间效率。在HashMap<Long,Long>结构中,只有Key和Value所存放的两个长整型数据是有效数据,共16B(2×8B)。这两个长整型数据包装成java.lang.Long对象之后,就分别具有8B的MarkWord、8B的Klass指针,在加8B存储数据的long值。在这两个Long对象组成Map.Entry之后,又多了16B的对象头,然后一个8B的next字段和4B的int型的hash字段,为了对齐,还必须添加4B的空白填充,最后还有HashMap中对这个Entry的8B的引用,这样增加两个长整型数字,实际耗费的内存为(Long(24B)×2)+Entry(32B)+HashMapRef(8B)=88B,空间效率为16B/88B=18%,实在太低了。

eclipse调优

①优化一

考虑到实际情况:Eclipse使用者甚多,它的编译代码我们可以认为是可靠的,不需要在加载的时候再进行字节码验证,因此通过参数-Xverify:none禁止掉字节码验证过程也可作为一项优化措施。

②优化二

前面说过,除了类加载时间以外,在VisualGC的监视曲线中显示了两项很大的非用户程序耗时:编译时间(CompileTime)和垃圾收集时间(GCTime)。垃圾收集时间读者应该非常清楚了,而编译时间是什么呢?程序在运行之前不是已经编译了吗?虚拟机的JIT编译与垃圾收集一样,是本书的一个重要部分,后面有专门章节讲解,这里先简单介绍一下:编译时间是指虚拟机的JIT编译器(JustInTimeCompiler)编译热点代码(HotSpotCode)的耗时。我们知道Java语言为了实现跨平台的特性,Java代码编译出来后形成的Class文件中存储的是字节码(ByteCode),虚拟机通过解释方式执行字节码命令,比起C/C++编译成本地二进制代码来说,速度要慢不少。为了解决程序解释执行的速度问题,JDK1.2以后,虚拟机内置了两个运行时编译器[3],如果一段Java方法被调用次数达到一定程度,就会被判定为热代码交给JIT编译器即时编译为本地代码,提高运行速度(这就是HotSpot虚拟机名字的由来)。甚至有可能在运行期动态编译比C/C++的编译期静态译编出来的代码更优秀,因为运行期可以收集很多编译器无法知道的信息,甚至可以采用一些很激进的优化手段,在优化条件不成立的时候再逆优化退回来。所以Java程序只要代码没有问题(主要是泄漏问题,如内存泄漏、连接泄漏),随着代码被编译得越来越彻底,运行速度应当是越运行越快的。Java的运行期编译最大的缺点就是它进行编译需要消耗程序正常的运行时间,这也就是上面所说的“编译时间”。(热编译)

八、类文件结构

①无关性的基石

各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)
是构成平台无关性的基石。实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。基于安全方面的考虑,Java虚拟机规范要求在Class文件中使用许多强制性的语法和结构化约束,但任一门功能性语言都可以表示为一个能被Java虚拟机所接受的有效的Class文件。

②Class类文件的结构

(1)魔数与Class文件的版本

Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地

排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎

全部是程序运行的必要数据,没有空隙存在。

无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个
字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8
编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地

以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张
表,它由表6-1所示的数据项构成。

每个Class文件的头4个字节称为魔数(MagicNumber),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如gif或者jpeg等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(MinorVersion),第7和第8个字节是主版本号(MajorVersion)。Java的版本号是从45开始

的,JDK1.1之后的每个JDK大版本发布主版本号向上加1(JDK1.0~1.1使用了45.0~45.3的版本号),高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。
图6-2显示的是使用十六进制编辑器WinHex打开这个Class文件的结果,可以清楚地看见开头4个字节的十六进制表示是0xCAFEBABE,代表次版本号的第5个和第6个字节值为0x0000,而主版本号的值为0x0032,也即是十进制的50,该版本号说明这个文件是可以被
JDK1.6或以上版本虚拟机执行的Class文件。

可以通过class文件的16进制编码,查看jdk的版本号

(2)常量池

1、紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。


2、常量池中主要存放两大类常量:字面量(Literal)和符号引用(SymbolicReferences)。
字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。而符
号引用则属于编译原理方面的概念,包括了下面三类常量:
类和接口的全限定名(FullyQualifiedName)
字段的名称和描述符(Descriptor)
方法的名称和描述符


3、Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟
机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段
的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正
的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的
符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中

常量池中每一项常量都是一个表

4、代码清单6-2使用Javap命令输出常量表
C:\>javap-verboseTestClass
Compiledfrom"TestClass.java"
publicclassorg.fenixsoft.clazz.TestClassextendsjava.lang.Object
SourceFile:"TestClass.java"
minorversion:0
majorversion:50
Constantpool:
const#1=class#2;//org/fenixsoft/clazz/TestClass
const#2=Ascizorg/fenixsoft/clazz/TestClass;
const#3=class#4;//java/lang/Object
const#4=Ascizjava/lang/Object;
const#5=Ascizm;
const#6=AscizI;
const#7=Asciz<init>;
const#8=Asciz()V;
const#9=AscizCode;
const#10=Method#3.#11;//java/lang/Object."<init>":()V
const#11=NameAndType#7:#8;//"<init>":()V
const#12=AscizLineNumberTable;
const#13=AscizLocalVariableTable;
const#14=Ascizthis;
const#15=AscizLorg/fenixsoft/clazz/TestClass;
const#16=Ascizinc;
const#17=Asciz()I;
const#18=Field#1.#19;//org/fenixsoft/clazz/TestClass.m:I
const#19=NameAndType#5:#6;//m:I
const#20=AscizSourceFile;
const#21=AscizTestClass.java;

(3)访问标志

在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识
别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类
型;是否定义为abstract类型;如果是类的话,是否被声明为final等。具体的标志位以及标志
的含义见表6-7。

access_flags中一共有16个标志位可以使用,当前只定义了其中8个[1],没有使用到的标志
位要求一律为0。以代码清单6-1中的代码为例,TestClass是一个普通Java类,不是接口、枚
举或者注解,被public关键字修饰但没有被声明为final和abstract,并且它使用了JDK1.2之后
的编译器进行编译,因此它的ACC_PUBLIC、ACC_SUPER标志应当为真,而ACC_FINAL、
ACC_INTERFACE、ACC_ABSTRACT、ACC_SYNTHETIC、ACC_ANNOTATION、
ACC_ENUM这6个标志应当为假,因此它的access_flags的值应为:0x0001|0x0020=0x0021。
从图6-5中可以看出,access_flags标志(偏移地址:0x000000EF)的确为0x0021。

(4)类索引、父类索引与接口索引集合

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集
合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承
关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由
于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java
类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。接口索引集合就
用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身
是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。
类索引、父类索引和接口索引集合都按顺序排列在访问标志之后

(5)字段表结构

字段表(field_info)用于描述接口或者类中声明的变量。字段(field)包括类级变量以
及实例级变量,但不包括在方法内部声明的局部变量。我们可以想一想在Java中描述一个字
段可以包含什么信息?可以包括的信息有:字段的作用域(public、private、protected修饰
符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰
符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类
型、对象、数组)、字段名称。

跟随access_flags标志的是两项索引值:name_index和descriptor_index。它们都是对常量
池的引用,分别代表着字段的简单名称以及字段和方法的描述符。现在需要解释一下“简单
名称”、“描述符”以及前面出现过多次的“全限定名”这三种特殊字符串的概念。
全限定名和简单名称很好理解,以代码清单6-1中的代码为
例,“org/fenixsoft/clazz/TestClass”是这个类的全限定名,仅仅是把类全名中的“.”替换成
了“/”而已,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一
个“;”表示全限定名结束。简单名称是指没有类型和参数修饰的方法或者字段名称,这个类
中的inc()方法和m字段的简单名称分别是“inc”和“m”。

对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义
为“java.lang.String[][]”类型的二维数组,将被记录为:“[[Ljava/lang/String;”,一个整型数
组“int[]”将被记录为“[I”。
用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的
严格顺序放在一组小括号“()”之内。如方法voidinc()的描述符为“()V”,方法
java.lang.StringtoString()的描述符为“()Ljava/lang/String;”,方法int
indexOf(char[]source,intsourceOffset,intsourceCount,char[]target,inttargetOffset,int
targetCount,intfromIndex)的描述符为“([CII[CIII)I”。

(6)方法表集合类似字段表集合

(7)属性表集合

属性表(attribute_info)在前面的讲解之中已经出现过数次,在Class文件、字段表、方
法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。

九、虚拟机类加载机制

①类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、

验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、

使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking),

这7个阶段的发生顺序如图7-1所示。

类初始化的五中场景(主动引用)

1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,

如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常

见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个

类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)

的时候,以及调用一个类的静态方法的时候。

2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有

进行过初始化,则需要先触发其初始化。

3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父

类的初始化。

4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类)

虚拟机会先初始化这个主类。

5)当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle

实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,

并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

对于这5种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:“有

且只有”,这5种场景中的行为称为对一个类进行主动引用。除此之外,

所有引用类的方式都不会触发初始化,称为被动引用。

②类加载过程

(1)虚拟机需要做的工作

1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

(2)类加载形式

例如“通过一个类的全限定名来获取定义此类的二进制字节流”这条,它没有指明二进制字节流要从一个Class文件中获取,准确地说是根本没有指明要从哪里获取、怎样获取。虚拟机设计团队在加载阶段搭建了一个相当开放的、广阔的“舞台”

例如:
从ZIP包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。

从网络中获取,这种场景最典型的应用就是Applet。

运行时计算生成,这种场景使用得最多的就是动态代理技术在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass来为特定接口生成形为“*$Proxy”的代理类的二进制字节流。

由其他文件生成,典型场景是JSP应用,即由JSP文件生成对应的Class类。

从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如SAPNetweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。……

(3)数组的类记载机制

对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,

它是由Java虚拟机直接创建的。但数组类与类加载器仍然有很密切的关系,

因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)

最终是要靠类加载器去创建,一个数组类(下面简称为C)创建过程就遵循以下规则:

如果数组的组件类型(ComponentType,指的是数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将在加载该组件类型的类加载器的类名称空间上被标识(这点很重要,在7.4节会介绍到,一个类必须与类加载器一起确定唯一性)。如果数组的组件类型不是引用类型(例如int[]数组),Java虚拟机将会把数组C标记为与引导类加载器关联。数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public。

③验证

(1)需要验证的原因

虚拟机如果不检查输入的字节流,对其完全信任的话,很可
能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工
作。

(2)4个阶段验证

1)文件格式验证

第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处
理。这一阶段可能包括下面这些验证点:
是否以魔数0xCAFEBABE开头。
主、次版本号是否在当前虚拟机处理范围之内。
常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。
Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
……


这阶段的验证是基于二进制字节流进行
的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的
3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。

2)元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范
的要求,这个阶段可能包括的验证点如下:
这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合
规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
……
第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规
范的元数据信息。

3)节码验证

第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,
确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这
样的情况:在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中。
保证跳转指令不会跳转到方法体以外的字节码指令上。
保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
……

4)符号引用验证


最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将
在连接的第三阶段——解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中
的各种符号引用)的信息进行匹配性校验,通常需要校验下列内容:
符号引用中通过字符串描述的全限定名是否能找到对应的类。
在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。
……
符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如
java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、但不是一定必要(因为对程序运行期没有影响)的阶段。如果所运行的全部代码(包括自己编写的及第三方包中的代码)都已经被反复使用和验证过,那么在实施阶段就可以考虑使用-verify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

④准备

(1)变量赋默认值

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所

使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概

念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量)

,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

其次,这里所说的初始值“通常情况”下是数据类型的零值。在“通常情况”

下初始值是零值,那相对的会有一些“特殊情况”:如果类字段的字段属性表中

存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue

属性所指定的值,假设上面类变量value的定义变为:publicstaticfinalintvalue=123;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

⑤解析

(1)概念

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用(SymbolicReferences):符号引用以一组符号来描述所引用的目标,

符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,

引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各
不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义

在Java虚拟机规范的Class文件格式中。
直接引用(DirectReferences):直接引用可以是直接指向目标的指针、

相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现

的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引

用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

(2)对一个符号多次解析的情况

对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,

虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,

并把常量标识为已解析状态)从而避免解析动作重复进行。无论是否真正执行了

多次解析动作,虚拟机需要保证的是在同一个实体中,如果一个符号引用之前已经

被成功解析过,那么后续的引用解析请求就应当一直成功;同样的,如果第一次解

析失败了,那么其他指令对这个符号的解析请求也应该收到相同的异常。

(3)对于invokedynamic指令

对于invokedynamic指令,上面规则则不成立。当碰到某个前面已经由invokedynamic指令
触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生
效。因为invokedynamic指令的目的本来就是用于动态语言支持(目前仅使用Java语言不会生
成这条字节码指令),它所对应的引用称为“动态调用点限定符”(DynamicCallSite
Specifier),这里“动态”的含义就是必须等到程序实际运行到这条指令的时候,解析动作才
能进行。相对的,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有
开始执行代码时就进行解析。

(4)解析类型

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点
限定符7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、
CONSTANT_Fieldref_info、CONSTANT_Methodref_info、
CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、
CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info7种常量类型

⑥初始化

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)

中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中

只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前

面的静态语句块可以赋值,但是不能访问,如代码清单7-5中的例子所示。

代码清单7-5非法向前引用变量

publicclassTest{

static{

i=0;//给变量赋值可以正常编译通过

System.out.print(i);//这句编译器会提示"非法向前引用"

}s

taticinti=1;

}

<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不

需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<

clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定

是java.lang.Object。

由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子

类的变量赋值操作,如在代码清单7-6中,字段B的值将会是2而不是1。

代码清单7-6<clinit>()方法执行顺序

public static class Parent {

    public static int A = 1;

    static {

        A = 2;

    }

}

public static class Sub extends Parent {

    public static int B = A;

}

 public static void main(String[] args){

     System.out.println(Sub.B);

 }

<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,

也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。

但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。

只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞[2],在实际应用中这种阻塞往往是

很隐蔽的。

⑦类加载器

(1)比较两个类是否是同一个类

比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意
义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类
加载器不同,那这两个类就必定不相等。这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情
况。

(2)双亲委派模型

1)启动类加载器

启动类加载器(BootstrapClassLoader):前面已经介绍过,这个类将器负责将存放在<
JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机
识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)
类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加
载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可

2)扩展类加载器

扩展类加载器(ExtensionClassLoader):这个加载器由sun.misc.Launcher

$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系

统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

3)应用类加载器

应用程序类加载器(ApplicationClassLoader):这个类加载器由sun.misc.Launcher$App

ClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回

值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类

库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一

般情况下这个就是程序中默认的类加载器。

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当

有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系

来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己

去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是

如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈

自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自

己去加载。


解释我们程序的结果:
所以当我们加载自己写的java.lang.Object时,会默认调用AppliationClassLoader,这是系统提供的类加载器,肯定支持”双亲委派模型”,所以我们的请求会一步步提交到BootstrapClassLoader那里,这个类默认加载的类位于$JAVA_HOME/jre/lib下面的rt.jar包,可以找到我们需要的java.lang.Object类,所以加载的自然就不是我们自己写的Object类了.---------------------本文来自坦GA的CSDN博客,全文地址请点击:https://blog.csdn.net/tanga842428/article/details/52717681?utm_source=copy

十、虚拟机字节码执行引擎

①运行时栈帧结构

(1)栈帧概念

栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。

每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完
全确定了,并且写入到方法表的Code属性之中[2],因此一个栈帧需要分配多少内存,不会受
到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。对于执行引擎来
说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(CurrentStack
Frame),与这个栈帧相关联的方法称为当前方法(CurrentMethod)。执行引擎运行的所有
字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图8-1所示。

(2)局部变量表

1)概念

局部变量表(LocalVariableTable)是一组变量值存储空间,用于存放方法参数和方法
内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数
据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以变量槽(VariableSlot,下称Slot)为最小单位

对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot()空间。Java
语言中明确的(reference类型则可能是32位也可能是64位)64位的数据类型只有long和double
两种。值得一提的是,这里把long和double数据类型分割存储的做法与“long和double的非原
子性协定”中把一次long和double数据类型读写分割为两次32位读写的做法有些类似由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题[5]。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最
大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64
位数据类型的变量,则说明会同时使用n和n+1两个Slot。对于两个相邻的共同存放一个64位
数据的两个Slot,不允许采用任何方式单独访问其中的某一个,Java虚拟机规范中明确要求
了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。

在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如
果执行的是实例方法(非static的方法),那局部变量表中第0位索引的Slot默认是用于传递方
法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参
数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体
内部定义的变量顺序和作用域分配其余的Slot。
为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,
其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的
作用域,那这个变量对应的Slot就可以交给其他变量使用。

2)操作数栈

操作数栈(OperandStack)也常称为操作栈,它是一个后入先出(LastInFirst
Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的
max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和
double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任
何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,
会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算
术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进
行参数传递的。

例如:整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素
已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加
的结果入栈。

3)动态链接

Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符
号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接
引用,这种转化称为静态解析(处在类加载机制中的一个过程—解析,并在其中提到了解析就是将class文件中的一部分符号引用直接解析为直接引用的过程,方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。可以概括为:编译期可知、运行期不可变。)。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接

4)方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。

第一种方式是执行引擎遇任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者。这种退出方法的方式称为正常完成出口。

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得
到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异
常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出
方法的方式称为异常完成出口。
一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可
能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈
帧中一般不会保存这部分信息。

5)附加信息 

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与
调试相关的信息,这部分信息完全取决于具体的虚拟机实现。

②方法调用

(1)解析

虚方法和非虚方法

继续前面关于方法调用的话题,所有方法调用中的目标方法在Class文件里面都是一个常

量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这

种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方

法的调用版本在运行期是不可改变的。 换句话说,调用目标在程序代码写好、 编译器进行编

译时就必须确定下来。 这类方法的调用称为解析(Resolution)。

在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和

私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决

定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解

析。

与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。
invokestatic:调用静态方法。
invokespecial:调用实例构造器<init>方法、 私有方法和父类方法。
invokevirtual:调用所有的虚方法。
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方
法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令
的分派逻辑是由用户所设定的引导方法决定的。
 

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的
调用版本,符合这个条件的有静态方法、 私有方法、 实例构造器、 父类方法4类,它们在类

加载的时候就会把符号引用解析为该方法的直接引用。 这些方法可以称为非虚方法,与之相

反,其他方法称为虚方法(除去final方法,后文会提到)。Java中的非虚方法除了使用invokestatic、 invokespecial调用的方法之外还有一种,就是被
final修饰的方法。 虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,
没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯
一的。 在Java语言规范中明确说明了final方法是一种非虚方法。
解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉
及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。

(2)分派

1)静态分派

/**

 * 方法静态分派演示

 *

 * @author zzm

 */

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 guy) {

        System.out.println("hello,gentleman!");

    }

    public void sayHello(Woman guy) {

        System.out.println("hello,lady!");

    }

    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”称为变量的静态类型(Static Type),或者叫做的外观类型
(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际
类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的
静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运
行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。 静态分派的典型应用
是方法重载。 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执
行的。 另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不
是“唯一的”,往往只能确定一个“更加合适的”版本。

2)动态分派

/**

 * 方法动态分派演示

 *

 * @author zzm

 */

public class DynamicDispatch {

    static abstract class Human {

        protected abstract void sayHello();

    }

    static class Man extends Human {

        @Override

        protected void sayHello() {

            System.out.println("man say hello");

        }

    }

    static class Woman extends Human {

        @Override

        protected void sayHello() {

            System.out.println("woman say hello");

        }

    }

    public static void main(String[] args) {

        Human man = new Man();

        Human woman = new Woman();

        man.sayHello();

        woman.sayHello();

        man = new Woman();

        man.sayHello();

    }

}

运行结果:
man say hello
woman say hello
woman say hello 

导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何
根据实际类型来分派方法执行版本的呢? 

原因就需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解
析过程大致分为以下几个步骤:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校
验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回
java.lang.IllegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调
用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过
程就是Java语言中方法重写的本质。 我们把这种在运行期根据实际类型确定方法执行版本的
分派过程称为动态分派。

3)单分配和多分配

 方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。 单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

代码清单8-10 单分派和多分派

/**

*单分派、 多分派演示

*@author zzm

*/

public class Dispatch{

 static class QQ{}

 static class_360{}

 public static class Father{

    public void hardChoice(QQ arg){

        System.out.println("father choose qq");

    }

public void hardChoice(_360 arg){

    System.out.println("father choose 360");

    }

}

public static class Son extends Father{

    public void hardChoice(QQ arg){

        System.out.println("son choose qq");

}

public void hardChoice(_360 arg){

    System.out.println("son choose 360");

}

}

public static void main(String[]args){

    Father father=new Father();

    Father son=new Son();

    father.hardChoice(new_360());

    son.hardChoice(new QQ());

    }

运行结果:

father choose 360

son choose qq

我们来看看编译阶段编译器的选择过程,也就是静态分派的过程。 这时选择目标方法的
依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。 这次选择结果的
最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向
Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。 因为是根据两个宗量
进行选择,所以Java语言的静态分派属于多分派类型。
再看看运行阶段虚拟机的选择,也就是动态分派的过程。 在执行“son.hardChoice(new
QQ())”这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于
编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的
参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时参数的静态类型、 实际类型都对方法的
选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是
Father还是Son。 因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类
型。

4)虚拟机动态分派的实现

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的
方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实
现都不会真正地进行如此频繁的搜索。 面对这种情况,最常用的“稳定优化”手段就是为类在
方法区中建立一个虚方法表(Vritual Method Table,也称为vtable,与此对应的,在
invokeinterface执行时也会用到接口方法表——Inteface Method Table,简称itable),使用虚
方法表索引来代替元数据查找以提高性能。 我们先看看代码清单8-10所对应的虚方法表结构
示例,如图8-3所示。 

虚方法表中存放着各个方法的实际入口地址。 如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。 如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。 图8-3中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。 但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。
为了程序实现上的方便,具有相同签名的方法,在父类、 子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
上文中笔者说方法表是分派调用的“稳定优化”手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(Inline Cache)和基于“类型继承关系分析”(ClassHierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的“激进优化”手段来获得更高的性能,关于这两种优化技术的原理和运作过程,读者可以参考本书第11章中的
相关内容。 


编译期和运行期的区别:

编译期: 是指把源码交给编译器编译成计算机可以执行的文件的过程.在Java中也就是把Java代码编成class文件的过程.编译期只是做了一些翻译功能,并没有把代码放在内存中运行起来,而只是把代码当成文本进行操作,比如检查错误.

运行期:是把编译后的文件交给计算机执行.直到程序运行结束.所谓运行期就把在磁盘中的代码放到内存中执行起来.在Java中把磁盘中的代码放到内存中就是类加载过程.类加载是运行期的开始部分,后面会介绍下类加载.

编译期分配内存并不是说在编译期就把程序所需要的空间在内存中分配好,而是说在编译期生成的代码中产生一些指令,在运行代码时通过这些指令把程序所需的内存分配好.只不过在编译期的时候就知道分配的大小,并且知道这些内存的位置.而运行期分配内存是指只有在运行期才确定内存的大小,存放的位置.

--------------------- 本文来自 SHEN_ZIYUAN 的CSDN 博客 ,全文地址请点击:https://blog.csdn.net/qq_26225663/article/details/79593264?utm_source=copy 

(3)动态类型语言支持

1)动态类型语言

什么是动态类型语言[1]?动态类型语言的关键特征是它的类型检查的主体过程是在运行
期而不是编译期,满足这个特征的语言有很多,常用的包括:APL、 Clojure、 Erlang、
Groovy、 JavaScript、 Jython、 Lisp、 Lua、 PHP、 Prolog、 Python、 Ruby、 Smalltalk和Tcl等。 相
对的,在编译期就进行类型检查过程的语言(如C++和Java等)就是最常用的静态类型语言。

③基于栈的字节码解释执行引擎

(1)解释执行

如今,基于物理机、 Java虚拟机,或者非Java的其他高级语言虚拟机(HLLVM)的语

言,大多都会遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析

和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST)。 对于一门具体语

言的实现来说,词法分析、 语法分析以至后面的优化器和目标代码生成器都可以选择独立于

执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。 也可以选择把其中

一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java

语言。 又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的

JavaScript执行器。

Java语言中,Javac编译器完成了程序代码经过词法分析、 语法分析到抽象语法树,再遍
历语法树生成线性的字节码指令流的过程。 因为这一部分动作是在Java虚拟机之外进行的,
而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。

(2)基于栈的指令集与基于寄存器的指令集

Java编译器输出的指令流,基本上[1]是一种基于栈的指令集架构(Instruction Set

Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。

与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址

指令集,说得通俗一些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄

存器进行工作。 那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?

举个最简单的例子,分别使用这两种指令集计算“1+1”的结果,基于栈的指令集会是这

样子的:

iconst_1

iconst_1

iadd

istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、 相加,然

后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。

如果基于寄存器,那程序可能会是这个样子:

mov eax,1

add eax,1

mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存

器里面。

了解了基于栈的指令集与基于寄存器的指令集的区别后,读者可能会有进一步的疑问,

这两套指令集谁更好一些呢?

应该这么说,既然两套指令集会同时并存和发展,那肯定是各有优势的,如果有一套指

令集全面优于另外一套的话,就不会存在选择的问题了。

基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供[2],程序直接依赖这些

硬件寄存器则不可避免地要受到硬件的约束。 例如,现在32位80x86体系的处理器中提供了8

个32位的寄存器,而ARM体系的CPU(在当前的手机、 PDA中相当流行的一种处理器)则提

供了16个32位的通用寄存器。 如果使用栈架构的指令集,用户程序不会直接使用这些寄存

器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、 栈顶缓存等)

放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。 栈架构的指令集还有一

些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集

中还需要存放参数)、 编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈

上操作)等。

栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。 所有主流物理机的指令集都

是寄存器架构也从侧面印证了这一点。

虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器

架构多,因为出栈、 入栈操作本身就产生了相当多的指令数量。 更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度

的瓶颈。 尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内

存访问,但这也只能是优化措施而不是解决本质问题的方法。 由于指令数量和内存访问的原

因,所以导致了栈架构指令集的执行速度会相对较慢。

十一、类加载及执行子系统的案例与实战

①Tomcat正统类加载器架构

在部署Web应用时,单独的一个ClassPath就无法满足需求了,所以
各种Web服务器都“不约而同”地提供了好几个ClassPath路径供用户存放第三方类库,这些路
径一般都以“lib”或“classes”命名。 被放置到不同路径中的类库,具备不同的访问范围和服务
对象,通常,每一个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库。
现在,笔者就以Tomcat服务器[1]为例,看一看Tomcat具体是如何规划用户类库结构和类加载
器的。
在Tomcat目录结构中,有3组目录(“/common/*”、 “/server/*”和“/shared/*”)可以存放
Java类库,另外还可以加上Web应用程序自身的目录“/WEB-INF/*”,一共4组,把Java类库放
置在这些目录中的含义分别如下。
放置在/common目录中:类库可被Tomcat和所有的Web应用程序共同使用。
放置在/server目录中:类库可被Tomcat使用,对所有的Web应用程序都不可见。
放置在/shared目录中:类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。
放置在/WebApp/WEB-INF目录中:类库仅仅可以被此Web应用程序使用,对Tomcat和其
他Web应用程序都不可见。
为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类
加载器,这些类加载器按照经典的双亲委派模型来实现,其关系如图9-1所示。

灰色背景的3个类加载器是JDK默认提供的类加载器,这3个加载器的作用在第7章中已
经介绍过了。 而CommonClassLoader、 CatalinaClassLoader、 SharedClassLoader和
WebappClassLoader则是Tomcat自己定义的类加载器,它们分别加载/common/*、 /server/*、
/shared/*和/WebApp/WEB-INF/*中的Java类库。 其中WebApp类加载器和Jsp类加载器通常会
存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp
类加载器。而JasperLoader的加载范围仅仅是这个JSP文件所
编译出来的那一个Class,它出现的目的就是为了被丢弃:当服务器检测到JSP文件被修改
时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件
的HotSwap功能。
对于Tomcat的6.x版本,只有指定了tomcat/conf/catalina.properties配置文件的server.loader
和share.loader项后才会真正建立CatalinaClassLoader和SharedClassLoader的实例,否则会用到
这两个类加载器的地方都会用CommonClassLoader的实例代替,而默认的配置文件中没有设
置这两个loader项,所以Tomcat 6.x顺理成章地把/common、 /server和/shared三个目录默认合
并到一起变成一个/lib目录,这个目录里的类库相当于以前/common目录中类库的作用。 这是
Tomcat设计团队为了简化大多数的部署场景所做的一项改进,如果默认设置不能满足需要,
用户可以通过修改配置文件指定server.loader和share.loader的方式重新启用Tomcat 5.x的加载
器架构。

十三、早期(编译期)优化

①Javac编译器

1)编译过程

从SunJavac的代码来看,编译过程大致可以分为3个过程,分别是:
解析与填充符号表过程。
插入式注解处理器的注解处理过程。
分析与字节码生成过程。
这3个步骤之间的关系与交互顺序如图10-4所示。 

Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类,上述3个过程的代码逻
辑集中在这个类的compile()和compile2()方法中,其中主体代码如图10-5所示,整个编
译最关键的处理就由图中标注的8个方法来完成,下面我们具体看一下这8个方法实现了什么
功能。 

2)解析与填充符号表

1.词法、 语法分析

词法分析是将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的

最小元素,而标记则是编译过程的最小元素,关键字、 变量名、 字面量、 运算符都可以成为

标记,如“int a=b+2”这句代码包含了6个标记,分别是int、 a、 =、 b、 +、 2,虽然关键字int由

3个字符构成,但是它只是一个Token,不可再拆分。 在Javac的源码中,词法分析过程由

com.sun.tools.javac.parser.Scanner类来实现。

语法分析是根据Token序列构造抽象语法树的过程,抽象语法树(Abstract Syntax

Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表

着程序代码中的一个语法结构(Construct),例如包、 类型、 修饰符、 运算符、 接口、 返回

值甚至代码注释等都可以是一个语法结构。

2.填充符号表 

完成了语法分析和词法分析之后,下一步就是填充符号表的过程,也就是图10-5中
enterTrees()方法(图10-5中的过程1.2)所做的事情。 符号表(Symbol Table)是由一组符
号地址和符号信息构成的表格,读者可以把它想象成哈希表中K-V值对的形式(实际上符号
表不一定是哈希表实现,可以是有序符号表、 树状符号表、 栈结构符号表等)。 符号表中所
登记的信息在编译的不同阶段都要用到。 在语义分析中,符号表所登记的内容将用于语义检
查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。 在目标代码生成阶
段,当对符号名进行地址分配时,符号表是地址分配的依据。
在Javac源代码中,填充符号表的过程由com.sun.tools.javac.comp.Enter类实现,此过程的
出口是一个待处理列表(To Do List),包含了每一个编译单元的抽象语法树的顶级节点,
以及package-info.java(如果存在的话)的顶级节点。

3)注解处理器 

4)语义分析与字节码生成 

语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确
的源程序的抽象,但无法保证源程序是符合逻辑的。 而语义分析的主要任务是对结构上正确
的源程序进行上下文有关性质的审查,如进行类型审查。 

1.标注检查

2.数据及控制流分析 

3.解语法糖 

4.字节码生成

②语法糖

1)泛型与类型擦除 

代码清单10-4 当泛型遇见重载1
public class GenericTypes{
public static void method(List<String>list){
System.out.println("invoke method(List<String>list)");
}p
ublic static void method(List<Integer>list){
System.out.println("invoke method(List<Integer>list)");
}} 请
想一想,上面这段代码是否正确,能否编译执行?也许你已经有了答案,这段代码是
不能被编译的,因为参数List<Integer>和List<String>编译之后都被擦除了,变成了一样的
原生类型List<E>,擦除动作导致这两种方法的特征签名变得一模一样。 初步看来,无法重
载的原因已经找到了,但真的就是如此吗?只能说,泛型擦除成相同的原生类型只是无法重
载的其中一部分原因,请再接着看一看代码清单10-5中的内容。
代码清单10-5 当泛型遇见重载2
public class GenericTypes{
public static String method(List<String>list){
System.out.println("invoke method(List<String>list)");
return"";
}p
ublic static int method(List<Integer>list){
System.out.println("invoke method(List<Integer>list)");
return 1;
}p
ublic static void main(String[]args){
method(new ArrayList<String>());
method(new ArrayList<Integer>());
}}
执行结果:
invoke method(List<String>list)
invoke method(List<Integer>list)
代码清单10-5与代码清单10-4的差别是两个method方法添加了不同的返回值,由于这两
个返回值的加入,方法重载居然成功了,即这段代码可以被编译和执行[2]了。 这是对Java语
言中返回值不参与重载选择的基本认知的挑战吗?
代码清单10-5中的重载当然不是根据返回值来确定的,之所以这次能编译和执行成功,
是因为两个method()方法加入了不同的返回值后才能共存在一个Class文件之中。 第6章介
绍Class文件方法表(method_info)的数据结构时曾经提到过,方法重载要求方法具备不同的
特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择,但是在
Class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。 也就是说,两个方法
如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个Class文件
中的。
由于Java泛型的引入,各种场景(虚拟机解析、 反射等)下的方法调用都可能对原有的
基础产生影响和新的需求,如在泛型类中如何获取传入的参数化类型等。 因此,JCP组织对
虚拟机规范做出了相应的修改,引入了诸如Signature、 LocalVariableTypeTable等新的属性用
于解决伴随泛型而来的参数类型的识别问题,Signature是其中最重要的一项属性,它的作用
就是存储一个方法在字节码层面的特征签名[3],这个属性中保存的参数类型并不是原生类
型,而是包括了参数化类型的信息。 修改后的虚拟机规范[4]要求所有能识别49.0以上版本的
Class文件的虚拟机都要能正确地识别Signature参数。
另外,从Signature属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法
的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过
反射手段取得参数化类型的根本依据。 

2)自动装箱、 拆箱与遍历循环

3)条件编译

十四、晚期(运行期)优化

①解释器与编译器 

尽管并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚
拟机,如HotSpot、 J9等,都同时包含解释器与编译器[1]。 解释器与编译器两者各有优势:当
程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。
在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码
之后,可以获取更高的执行效率。 当程序运行环境中内存资源限制较大(如部分嵌入式系统
中),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。 同时,解释器还可
以作为编译器激进优化时的一个“逃生门”,让编译器根据概率选择一些大多数时候都能提升
运行速度的优化手段,当激进优化的假设不成立,如加载了新类后类型继承结构出现变化、
出现“罕见陷阱”(Uncommon Trap)时可以通过逆优化(Deoptimization)退回到解释状态继
续执行(部分没有解释器的虚拟机中也会采用不进行激进优化的C1编译器[2]担任“逃生门”的
角色),因此,在整个虚拟机执行架构中,解释器与编译器经常配合工作,如图11-1所示

HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler和Server Compiler,或者简称为C1编译器和C2编译器(也叫Opto编译器)。 目前主流的HotSpot虚拟机(Sun系列JDK 1.7及之前版本的虚拟机)中,默认采用解释器与其中一个编译器直接配合的方式工作,程序使用哪个编译器,取决于虚拟机运行的模式,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在Client模式或Server模式。 

无论采用的编译器是Client Compiler还是Server Compiler,解释器与编译器搭配使用的方
式在虚拟机中称为“混合模式”(Mixed Mode),用户可以使用参数“-Xint”强制虚拟机运行
于“解释模式”(Interpreted Mode),这时编译器完全不介入工作,全部代码都使用解释方式
执行。 另外,也可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”(Compiled Mode)[3],
这时将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过
程,可以通过虚拟机的“-version”命令的输出结果显示出这3种模式,如代码清单11-1所示,
请注意黑体字部分。
代码清单11-1 虚拟机执行模式
C:\>java-version
java version"1.6.0_22"
Java(TM)SE Runtime Environment(build 1.6.0_22-b04)
Dynamic Code Evolution 64-Bit Server VM(build 0.2-b02-internal,19.0-b04-internal,mixed mode)
C:\>java-Xint-version
java version"1.6.0_22"
Java(TM)SE Runtime Environment(build 1.6.0_22-b04)
Dynamic Code Evolution 64-Bit Server VM(build 0.2-b02-internal,19.0-b04-internal,interpreted mode)
C:\>java-Xcomp-version
java version"1.6.0_22"
Java(TM)SE Runtime Environment(build 1.6.0_22-b04)
Dynamic Code Evolution 64-Bit Server VM(build 0.2-b02-internal,19.0-b04-internal,compiled mode)
 

由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,
所花费的时间可能更长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收
集性能监控信息,这对解释执行的速度也有影响。 为了在程序启动响应速度与运行效率之间
达到最佳平衡,HotSpot虚拟机还会逐渐启用分层编译(Tiered Compilation)[4]的策略,分层
编译的概念在JDK 1.6时期出现,后来一直处于改进阶段,最终在JDK 1.7的Server模式虚拟
机中作为默认编译策略被开启。 分层编译根据编译器编译、 优化的规模与耗时,划分出不同
的编译层次,其中包括:
第0层,程序解释执行,解释器不开启性能监控功能(Profiling),可触发第1层编译。
第1层,也称为C1编译,将字节码编译为本地代码,进行简单、 可靠的优化,如有必要
将加入性能监控的逻辑。
第2层(或2层以上),也称为C2编译,也是将字节码编译为本地代码,但是会启用一些
编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
实施分层编译后,Client Compiler和Server Compiler将会同时工作,许多代码都可能会被
多次编译,用Client Compiler获取更高的编译速度,用Server Compiler来获取更好的编译质
量,在解释执行的时候也无须再承担收集性能监控信息的任务。
 

解释器(英语:Interpreter),又译为直译器,是一种电脑程序,能够把高级编程语言一行一行直接转译运行。解释器不会一次把整个程序转译出来,只像一位“中间人”,每次运行程序时都要先转成另一种语言再作运行,因此解释器的程序运行速度比较缓慢。它每转译一行程序叙述就立刻运行,然后再转译下一行,再运行,如此不停地进行下去。

②编译对象与触发条件 

(1)被即时编译器编译的“热点代码”类型

1)被多次调用的方法。

2)被多次执行的循环体。

尽管编译动作是由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象。 这种编译方式因为编译发生在方法执行过程之中,因此形象地称之为栈上替换(OnStack Replacement,简称为OSR编译,即方法栈帧还在栈上,方法就被替换了)  

(2)触发条件(热点探测)

1)基于采样的热点探测 

采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。 基于采样的热点探测的好处是实现简单、 高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。 

2)基于计数器的热点探测 

采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。 这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对来说更加精确和严谨。 

在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方
法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back EdgeCounter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈

值溢出了,就会触发JIT编译。

1、方法调用计数器

如果不做任何设置,执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照
解释方式执行字节码,直到提交的请求被编译器编译完成。 当编译工作完成之后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。
整个JIT编译的交互过程如图11-2所示。 

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相
对的执行频率,即一段时间之内方法被调用的次数。 当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)。 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。 另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。 


2、回边调用计数器

回边计数器,它的作用是统计一个方法中循环体代码执行的次数[2],在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。 显然,建立回边计数器统计的目的就是为了触发OSR编译。

关于回边计数器的阈值,虽然HotSpot虚拟机也提供了一个类似于方法调用计数器阈值-
XX:CompileThreshold的参数-XX:BackEdgeThreshold供用户设置,但是当前的虚拟机实际
上并未使用此参数,因此我们需要设置另外一个参数-XX:OnStackReplacePercentage来间接
调整回边计数器的阈值,其计算公式如下。
虚拟机运行在Client模式下,回边计数器阈值计算公式为:
方法调用计数器阈值(CompileThreshold)×OSR比率(OnStackReplacePercentage)/100
其中OnStackReplacePercentage默认值为933,如果都取默认值,那Client模式虚拟机的回
边计数器的阈值为13995。
虚拟机运行在Server模式下,回边计数器阈值的计算公式为:
方法调用计数器阈值(CompileThreshold)×(OSR比率(OnStackReplacePercentage)-解
释器监控比率(InterpreterProfilePercentage)/100
其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,如
果都取默认值,那Server模式虚拟机回边计数器的阈值为10700。
当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版
本,如果有,它将会优先执行已编译的代码,否则就把回边计数器的值加1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。 当超过阈值的时候,将会提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个执行过程如图11-3所示。      

③编译过程

在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代
码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。 用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,在禁止后台编译后,一旦达到JIT的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。 

(1)Client Compiler编译器

在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(HighLevel Intermediate Representaion,HIR)。 HIR使用静态单分配(Static Single Assignment,SSA)
的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易
实现。 在此之前编译器会在字节码上完成一部分基础优化,如方法内联、 常量传播等优化将
会在字节码被构造成HIR之前完成。
在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level
Intermediate Representation,LIR),而在此之前会在HIR上完成另外一些优化,如空值检查消
除、 范围检查消除等,以便让HIR达到更高效的代码表示形式。
最后阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR
上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。 Client Compiler的
大致执行过程如图11-4所示。

(2)Server Compiler编译器

而Server Compiler则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的
编译器,也是一个充分优化过的高级编译器,几乎能达到GNU C++编译器使用-O2参数时的
优化强度,它会执行所有经典的优化动作,如无用代码消除(Dead Code Elimination)、 循
环展开(Loop Unrolling)、 循环表达式外提(Loop Expression Hoisting)、 消除公共子表达
式(Common Subexpression Elimination)、 常量传播(Constant Propagation)、 基本块重排序
(Basic Block Reordering)等,还会实施一些与Java语言特性密切相关的优化技术,如范围
检查消除(Range Check Elimination)、 空值检查消除(Null Check Elimination,不过并非所
有的空值检查消除都是依赖编译器优化的,有一些是在代码运行过程中自动优化了)等。 另
外,还可能根据解释器或Client Compiler提供的性能监控信息,进行一些不稳定的激进优
化,如守护内联(Guarded Inlining)、 分支频率预测(Branch Frequency Prediction)等。 本章
的下半部分将会挑选上述的一部分优化手段进行分析和讲解。
Server Compiler的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器
架构(如RISC)上的大寄存器集合。 以即时编译的标准来看,Server Compiler无疑是比较缓
慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于Client Compiler
编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间
开销,所以也有很多非服务端的应用选择使用Server模式的虚拟机运行

(3)可以查看即时编译结果(略……)

④编译优化技术

(1)一个简单的优化例子

代码清单11-6 优化前的原始代码
static class B{
int value;
final int get(){
return value;
} } p
ublic void foo(){
y=b.get();
//……do stuff……
z=b.get();
sum=y+z;

首先需要明确的是,这些代码优化变换是建立在代码的某种中间表示或机器码之上,绝
不是建立在Java源码之上的,为了展示方便,笔者使用了Java语言的语法来表示这些优化技
术所发挥的作用。
代码清单11-6的代码已经非常简单了,但是仍有许多优化的余地。 第一步进行方法内联
(Method Inlining),方法内联的重要性要高于其他优化措施,它的主要目的有两个,一是
去除方法调用的成本(如建立栈帧等),二是为其他优化建立良好的基础,方法内联膨胀之
后可以便于在更大范围上采取后续的优化手段,从而获取更好的优化效果。 因此,各种编译
器一般都会把内联优化放在优化序列的最靠前位置。 内联后的代码如代码清单11-7所示。
代码清单11-7 内联后的代码
public void foo(){
y=b.value;
//……do stuff……
z=b.value;
sum=y+z;

第二步进行冗余访问消除(Redundant Loads Elimination),假设代码中间注释掉
的“dostuff……”所代表的操作不会改变b.value的值,那就可以把“z=b.value”替换为“z=y”,因
为上一句“y=b.value”已经保证了变量y与b.value是一致的,这样就可以不再去访问对象b的局
部变量了。 如果把b.value看做是一个表达式,那也可以把这项优化看成是公共子表达式消除
(Common Subexpression Elimination),优化后的代码如代码清单11-8所示。
代码清单11-8 冗余存储消除的代码
public void foo(){
y=b.value;
//……do stuff……
z=y;
sum=y+z;

第三步我们进行复写传播(Copy Propagation),因为在这段程序的逻辑中并没有必要使
用一个额外的变量“z”,它与变量“y”是完全相等的,因此可以使用“y”来代替“z”。 复写传播
之后程序如代码清单11-9所示。
代码清单11-9 复写传播的代码
public void foo(){
y=b.value;
//……do stuff……
y=y;
sum=y+y;
} 第
四步我们进行无用代码消除(Dead Code Elimination)。 无用代码可能是永远不会被
执行的代码,也可能是完全没有意义的代码,因此,它又形象地称为“Dead Code”,在代码
清单11-9中,“y=y”是没有意义的,把它消除后的程序如代码清单11-10所示。
代码清单11-10 进行无用代码消除的代码
public void foo(){
y=b.value;
//……do stuff……
sum=y+y;

经过四次优化之后,代码清单11-10与代码清单11-6所达到的效果是一致的,但是前者比
后者省略了许多语句(体现在字节码和机器码指令上的差距会更大),执行效率也会更高。 

(2)经典的优化技术

1)、公共子表达式消除

公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,它的含义是:如果一
个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么
E的这次出现就成为了公共子表达式。 对于这种表达式,没有必要花时间再对它进行计算,
只需要直接用前面计算过的表达式结果代替E就可以了。 如果这种优化仅限于程序的基本块
内,便称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优
化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common
Subexpression Elimination)。 

当这段代码进入到虚拟机即时编译器后,它将进行如下优化:编译器检测到“c * b”与“b
* c”是一样的表达式,而且在计算期间b与c的值是不变的。 因此,这条表达式就可能被视
为:
int d=E*12+a+(a+E);
这时,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一
种优化:代数化简(Algebraic Simplification),把表达式变为:
int d=E*13+a*2; 

2)、数组边界检查消除

将这个数组边界检查的例子放在更高的角度来看,大量的安全检查令编写Java程序比编
写C/C++程序容易很多,如数组越界会得到ArrayIndexOutOfBoundsException异常,空指针访
问会得到NullPointException,除数为零会得到ArithmeticException等,在C/C++程序中出现类
似的问题,一不小心就会出现Segment Fault信号或者Window编程中常见的“xxx内存不能为
Read/Write”之类的提示,处理不好程序就直接崩溃退出了。 但这些安全检查也导致了相同的
程序,Java要比C/C++做更多的事情(各种检查判断),这些事情就成为一种隐式开销,如
果处理不好它们,就很可能成为一个Java语言比C/C++更慢的因素。 要消除这些隐式开销,
除了如数组边界检查优化这种尽可能把运行期检查提到编译期完成的思路之外,另外还有一
种避免思路——隐式异常处理,Java中空指针检查和算术运算中除数为零的检查都采用了这
种思路。 举个例子,例如程序中访问一个对象(假设对象叫foo)的某个属性(假设属性叫
value),那以Java伪代码来表示虚拟机访问foo.value的过程如下。
if(foo!=null){
return foo.value;
}else{
throw new NullPointException();
} 在
使用隐式异常优化之后,虚拟机会把上面伪代码所表示的访问过程变为如下伪代码。
try{
return foo.value;
}catch(segment_fault){
uncommon_trap();
} 虚
拟机会注册一个Segment Fault信号的异常处理器(伪代码中的uncommon_trap()),
这样当foo不为空的时候,对value的访问是不会额外消耗一次对foo判空的开销的。 代价就是
当foo真的为空时,必须转入到异常处理器中恢复并抛出NullPointException异常,这个过程必
须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢。 当foo极
少为空的时候,隐式异常优化是值得的,但假如foo经常为空的话,这样的优化反而会让程
序更慢,还好HotSpot虚拟机足够“聪明”,它会根据运行期收集到的Profile信息自动选择最优
方案。
与语言相关的其他消除操作还有不少,如自动装箱消除(Autobox Elimination)、 安全点
消除(Safepoint Elimination)、 消除反射(Dereflection)等,笔者就不再一一介绍了。

3)、方法内联 

在前面的讲解之中我们提到过方法内联,它是编译器最重要的优化手段之一,除了消除
方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础,如代码清单11-
12所示的简单例子就揭示了内联对其他优化手段的意义:事实上testInline()方法的内部全
部都是无用的代码,如果不做内联,后续即使进行了无用代码消除的优化,也无法发现任
何“Dead Code”,因为如果分开来看,foo()和testInline()两个方法里面的操作都可能是
有意义的。
代码清单11-12 未做任何优化的字节码
public static void foo(Object obj){
if(obj!=null){
System.out.println("do something");
}}p
ublic static void testInline(String[]args){
Object obj=null;
foo(obj);

方法内联的优化行为看起来很简单,不过是把目标方法的代码“复制”到发起调用的方法
之中,避免发生真实的方法调用而已。 但实际上Java虚拟机中的内联过程远远没有那么简
单,因为如果不是即时编译器做了一些特别的努力,按照经典编译原理的优化理论,大多数
的Java方法都无法进行内联。
无法内联的原因其实在第8章中讲解Java方法解析和分派调用的时候就已经介绍过。 只
有使用invokespecial指令调用的私有方法、 实例构造器、 父类方法以及使用invokestatic指令
进行调用的静态方法才是在编译期进行解析的,除了上述4种方法之外,其他的Java方法调
用都需要在运行时进行方法接收者的多态选择,并且都有可能存在多于一个版本的方法接收
者(最多再除去被final修饰的方法这种特殊情况,尽管它使用invokevirtual指令调用,但也是
非虚方法,Java语言规范中明确说明了这点),简而言之,Java语言中默认的实例方法是虚

方法。
对于一个虚方法,编译期做内联的时候根本无法确定应该使用哪个方法版本,如果以代
码清单11-7中把“b.get()”内联为“b.value”为例的话,就是不依赖上下文就无法确定b的实际
类型是什么。 假如有ParentB和SubB两个具有继承关系的类,并且子类重写了父类的get()
方法,那么,是要执行父类的get()方法还是子类的get()方法,需要在运行期才能确
定,编译期无法得出结论。
由于Java语言提倡使用面向对象的编程方式进行编程,而Java对象的方法默认就是虚方
法,因此Java间接鼓励了程序员使用大量的虚方法来完成程序逻辑。 根据上面的分析,如果
内联与虚方法之间产生“矛盾”,那该怎么办呢?是不是为了提高执行性能,就要到处使用
final关键字去修饰方法呢?
为了解决虚方法的内联问题,Java虚拟机设计团队想了很多办法,首先是引入了一种名
为“类型继承关系分析”(Class Hierarchy Analysis,CHA)的技术,这是一种基于整个应用程
序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某
个类是否存在子类、 子类是否为抽象类等信息。
编译器在进行内联时,如果是非虚方法,那么直接进行内联就可以了,这时候的内联是
有稳定前提保障的。 如果遇到虚方法,则会向CHA查询此方法在当前程序下是否有多个目标
版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联就属于激进
优化,需要预留一个“逃生门”(Guard条件不成立时的Slow Path),称为守护内联(Guarded
Inlining)。 如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继
承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。 但如果加载了导致继承
关系发生变化的新类,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行
编译。
如果向CHA查询出来的结果是有多个版本的目标方法可供选择,则编译器还将会进行最
后一次努力,使用内联缓存(Inline Cache)来完成方法内联,这是一个建立在目标方法正常
入口之前的缓存,它的工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第
一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收
者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用
下去。 如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这
时才会取消内联,查找虚方法表进行方法分派。
所以说,在许多情况下虚拟机进行的内联都是一种激进优化,激进优化的手段在高性能
的商用虚拟机中很常见,除了内联之外,对于出现概率很小(通过经验数据或解释器收集到
的性能监控信息确定概率大小)的隐式异常、 使用概率很小的分支等都可以被激进优化“移
除”,如果真的出现了小概率事件,这时才会从“逃生门”回到解释状态重新执行。

4)、逃逸分析 

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术,它与类型继承
关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能
被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。 甚至还有可能被
外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何
途径访问到这个对象,则可能为这个变量进行一些高效的优化,如下所示。
栈上分配(Stack Allocation):Java虚拟机中,在Java堆上分配创建对象的内存空间几乎
是Java程序员都清楚的常识了,Java堆中的对象对于各个线程都是共享和可见的,只要持有
这个对象的引用,就可以访问堆中存储的对象数据。 虚拟机的垃圾收集系统可以回收堆中不
再使用的对象,但回收动作无论是筛选可回收对象,还是回收和整理内存都需要耗费时间。
如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的
主意,对象所占用的内存空间就可以随栈帧出栈而销毁。 在一般应用中,不会逃逸的局部对
象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁
了,垃圾收集系统的压力将会小很多。
同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃
逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就
不会有竞争,对这个变量实施的同步措施也就可以消除掉。
标量替换(Scalar Replacement):标量(Scalar)是指一个数据已经无法再分解成更小
的数据来表示了,Java虚拟机中的原始数据类型(int、 long等数值类型以及reference类型等)
都不能再进一步分解,它们就可以称为标量。 相对的,如果一个数据可以继续分解,那它就
称作聚合量(Aggregate),Java中的对象就是最典型的聚合量。 如果把一个Java对象拆散,
根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。 如果逃
逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时
候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代
替。 将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大的概率会
被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优
化手段创建条件。
关于逃逸分析的论文在1999年就已经发表,但直到Sun JDK 1.6才实现了逃逸分析,而且
直到现在这项优化尚未足够成熟,仍有很大的改进余地。 不成熟的原因主要是不能保证逃逸
分析的性能收益必定高于它的消耗。 如果要完全准确地判断一个对象是否会逃逸,需要进行
数据流敏感的一系列复杂分析,从而确定程序各个分支执行时对此对象的影响。 这是一个相
对高耗时的过程,如果分析完后发现没有几个不逃逸的对象,那这些运行期耗用的时间就白
白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成逃逸分
析。 还有一点是,基于逃逸分析的一些优化手段,如上面提到的“栈上分配”,由于HotSpot虚
拟机目前的实现方式导致栈上分配实现起来比较复杂,因此在HotSpot中暂时还没有做这项
优化。
在测试结果中,实施逃逸分析后的程序在MicroBenchmarks中往往能运行出不错的成
绩,但是在实际的应用程序,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定
的情况,或因分析过程耗时但却无法有效判别出非逃逸对象而导致性能(即时编译的收益)
有所下降,所以在很长的一段时间里,即使是Server Compiler,也默认不开启逃逸分析[1],甚
至在某些版本(如JDK 1.6 Update 18)中还曾经短暂地完全禁止了这项优化。
如果有需要,并且确认对程序运行有益,用户可以使用参数-XX:+DoEscapeAnalysis来
手动开启逃逸分析,开启之后可以通过参数-XX:+PrintEscapeAnalysis来查看分析结果。 有
了逃逸分析支持之后,用户可以使用参数-XX:+EliminateAllocations来开启标量替换,使用
+XX:+EliminateLocks来开启同步消除,使用参数-XX:+PrintEliminateAllocations查看标量
的替换情况。
 

十五、JAVA内存模型与线程

①计算机的内存模型

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统
带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。 在多处
理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main
Memory),如图12-1所示。 当多个处理器的运算任务都涉及同一块主内存区域时,将可能导
致各自的缓存数据不一致,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为
准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根
据协议来进行操作,这类协议有MSI、 MESI(Illinois Protocol)、 MOSI、 Synapse、 Firefly及
Dragon Protocol等。 在本章中将会多次提到的“内存模型”一词,可以理解为在特定的操作协
议下,对特定的内存或高速缓存进行读写访问的过程抽象。 不同架构的物理机器可以拥有不
一样的内存模型,而Java虚拟机也有自己的内存模型,并且这里介绍的内存访问操作与硬件
的缓存访问操作具有很高的可比性。 

②java内存模型

(1)主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与
介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的一部
分)。 每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类
比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝[4],线程对变量的
所有操作(读取、 赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量[5]。
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主
内存来完成,线程、 主内存、 工作内存三者的交互关系如图12-2所示。

(2)内存间交互操作 

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内
存、 如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来
完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、 不可再分的(对于double
和long类型的变量来说,load、 store、 read和write操作在某些平台上允许有例外 

lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放
后的变量才可以被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内
存中,以便随后的load动作使用。
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工
作内存的变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引
擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内
存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存
中,以便随后的write操作使用。
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入
主内存的变量中。

(3)对于volatile型变量的特殊规则 

1)volatile两点特性:

第一是保证此变量对所有线程的可
见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以
立即得知的。 使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的

顺序与程序代码中的执行顺序一致。 这个操作相当于一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;但如果有两个或更多CPU访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。 

2)可以指令重排序的原因

那为何说它禁止指令重排序呢?从硬件架构上讲,指令重排序是指CPU采用了允许将多
条指令不按程序规定的顺序分开发送给各相应电路单元处理。 但并不是说指令任意重
排,CPU需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。 譬如指令1把地
址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指
令2是有依赖的,它们之间的顺序不能重排——(A+10)*2与A*2+10显然不相等,但指令3
可以重排到指令1、 2之前或者中间,只要保证CPU执行后面依赖到A、 B值的操作时能获取到
正确的A和B值即可。 所以在本内CPU中,重排序看起来依然是有序的。 因此,lock
addl$0x0,(%esp)指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,
这样便形成了“指令重排序无法越过内存屏障”的效果。

(4)对于long和double型变量的特殊规则

Java内存模型要求lock、 unlock、 read、 load、 assign、 use、 store、 write这8个操作都具有
原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的
规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进
行,即允许虚拟机实现选择可以不保证64位数据类型的load、 store、 read和write这4个操作的
原子性,这点就是所谓的long和double的非原子性协定(Nonatomic Treatment ofdouble and
long Variables)。
如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它
们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值
的代表了“半个变量”的数值。
不过这种读取到“半个变量”的情况非常罕见(在目前商用Java虚拟机中不会出现),因
为Java内存模型虽然允许虚拟机不把long和double变量的读写实现成原子操作,但允许虚拟机
选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这样实现。 在实际开发
中,目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,
因此我们在编写代码时一般不需要把用到的long和double变量专门声明为volatile。

(5)原子性、 可见性与有序性 

Java内存模型是围绕着在并发过程中如何处理原子性、 可见性和有序性这3个特征来建立的,我们
逐个来看一下哪些操作实现了这3个特性

原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、 load、
assign、 use、 store和write,我们大致可以认为基本数据类型的访问读写是具备原子性的(例
外就是long和double的非原子性协定,读者只要知道这件事情就可以了,无须太过在意这些
几乎不会发生的例外情况)。
如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了
lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,
但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这
两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块
之间的操作也具备原子性。
可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即
得知这个修改。 上文在讲解volatile变量的时候我们已详细讨论过这一点。 Java内存模型是通
过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作
为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与
volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前
立即从主内存刷新。 因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则
不能保证这一点。
除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。 同步块的
可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、
write操作)”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一
旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事
情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看
见final字段的值。 如代码清单12-7所示,变量i与j都具备可见性,它们无须同步就能被其他
线程正确访问。
代码清单12-7 final与可见性
public static final int i;
public final int j;
static{
i=0;
//do something
}{/
/也可以选择在构造函数中初始化
j=0;
//do something
} 有
序性(Ordering):Java内存模型的有序性在前面讲解volatile时也详细地讨论过
了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有
序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。 前半句是指“线程内表
现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象
和“工作内存与主内存同步延迟”现象。
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile
关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻
只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同
步块只能串行地进入。

③Java与线程

(1)实现线程的方式

实现线程主要有3种方式:使用内核线程实现、 使用用户线程实现和使用用户线程加轻
量级进程混合实现。 

1).使用内核线程实现


内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支
持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行
调度,并负责将线程的任务映射到各个处理器上。 每个内核线程可以视为内核的一个分身,
这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核(MultiThreads Kernel)。
程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进
程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻
量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。 这种轻量
级进程与内核线程之间1:1的关系称为一对一的线程模型,如图12-3所示。

2).使用用户线程实现


从广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(User
Thread,UT),因此,从这个定义上来讲,轻量级进程也属于用户线程,但轻量级进程的实
现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制。
而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存
在的实现。 用户线程的建立、 同步、 销毁和调度完全在用户态中完成,不需要内核的帮助。
如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,
也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。 这
种进程与用户线程之间1:N的关系称为一对多的线程模型,如图12-4所示。

使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有
的线程操作都需要用户程序自己处理。 线程的创建、 切换和调度都是需要考虑的问题,而且
由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”、 “多处理器系统中如何
将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至不可能完成。 因而使用
用户线程实现的程序一般都比较复杂[1],除了以前在不支持多线程的操作系统中(如DOS)
的多线程程序与少数有特殊需求的程序外,现在使用用户线程的程序越来越少了,Java、
Ruby等语言都曾经使用过用户线程,最终又都放弃使用它。

3).使用用户线程加轻量级进程混合实现


线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用
户线程一起使用的实现方式。 在这种混合实现下,既存在用户线程,也存在轻量级进程。 用
户线程还是完全建立在用户空间中,因此用户线程的创建、 切换、 析构等操作依然廉价,并
且可以支持大规模的用户线程并发。 而操作系统提供支持的轻量级进程则作为用户线程和内
核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的
系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。 在这种混合模
式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系,如图12-5所示,这种
就是多对多的线程模型。
许多UNIX系列的操作系统,如Solaris、 HP-UX等都提供了N:M的线程模型实现。

4).Java线程的实现


Java线程在JDK 1.2之前,是基于称为“绿色线程”(Green Threads)的用户线程实现的,
而在JDK 1.2中,线程模型替换为基于操作系统原生线程模型来实现。 因此,在目前的JDK版
本中,操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射
的,这点在不同的平台上没有办法达成一致,虚拟机规范中也并未限定Java线程需要使用哪
种线程模型来实现。 线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码
和运行过程来说,这些差异都是透明的。
对于Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型实现的,一条
Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对
一的[2]。
而在Solaris平台中,由于操作系统的线程特性可以同时支持一对一(通过Bound Threads
或Alternate Libthread实现)及多对多(通过LWP/Thread Based Synchronization实现)的线程模
型,因此在Solaris版的JDK中也对应提供了两个平台专有的虚拟机参数:-XX:
+UseLWPSynchronization(默认值)和-XX:+UseBoundThreads来明确指定虚拟机使用哪种线
程模型。
[1]此处所讲的“复杂”与“程序自己完成线程操作”,并不限制程序中必须编写了复杂的实现用
户线程的代码,使用用户线程的程序,很多都依赖特定的线程库来完成基本的线程操作,这
些复杂性都封装在线程库之中。
[2]Windows下有纤程包(Fiber Package),Linux下也有NGPT(在2.4内核的年代)来实现
N:M模型,但是它们都没有成为主流

(2)Java线程调度 

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同
式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive ThreadsScheduling)。
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的
工作执行完了之后,要主动通知系统切换到另外一个线程上。 协同式多线程的最大好处是实
现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可
知的,所以没有什么线程同步的问题。 Lua语言中的“协同例程”就是这类实现。 它的坏处也
很明显:线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程
切换,那么程序就会一直阻塞在那里。 很久以前的Windows 3.x系统就是使用协同式来实现
多进程多任务的,相当不稳定,一个进程坚持不让出CPU执行时间就可能会导致整个系统崩
溃。
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切
换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时
间的话,线程本身是没有什么办法的)。 在这种实现线程调度的方式下,线程的执行时间是
系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢
占式调度[1]。 与前面所说的Windows 3.x的例子相对,在Windows 9x/NT内核中就是使用抢占
式来实现多进程的,当一个进程出了问题,我们还可以使用任务管理器把这个进程“杀掉”,
而不至于导致系统崩溃。
虽然Java线程调度是系统自动完成的,但是我们还是可以“建议”系统给某些线程多分配
一点执行时间,另外的一些线程则可以少分配一点——这项操作可以通过设置线程优先级来
完成。 Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至
Thread.MAX_PRIORITY),在两个线程同时处于Ready状态时,优先级越高的线程越容易被
系统选择执行。
不过,线程优先级并不是太靠谱,原因是Java的线程是通过映射到系统的原生线程上来
实现的,所以线程调度最终还是取决于操作系统,虽然现在很多操作系统都提供线程优先级
的概念,但是并不见得能与Java线程的优先级一一对应,如Solaris中有2147483648(232)种
优先级,但Windows中就只有7种,比Java线程优先级多的系统还好说,中间留下一点空位就
可以了,但比Java线程优先级少的系统,就不得不出现几个优先级相同的情况了,表12-1显
示了Java线程优先级与Windows线程优先级之间的对应关系,Windows平台的JDK中使用了除
THREAD_PRIORITY_IDLE之外的其余6种线程优先级。

(3)状态转换

Java语言定义了5种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种
状态,这5种状态分别如下。
新建(New):创建后尚未启动的线程处于这种状态。
运行(Runable):Runable包括了操作系统线程状态中的Running和Ready,也就是处于此
状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间。
无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,它们要等待被
其他线程显式地唤醒。 以下方法会让线程陷入无限期的等待状态:
●没有设置Timeout参数的Object.wait()方法。
●没有设置Timeout参数的Thread.join()方法。
●LockSupport.park()方法。
限期等待(Timed Waiting):处于这种状态的线程也不会被分配CPU执行时间,不过无
须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。 以下方法会让线程
进入限期等待状态:
●Thread.sleep()方法。
●设置了Timeout参数的Object.wait()方法。
●设置了Timeout参数的Thread.join()方法。
●LockSupport.parkNanos()方法。
●LockSupport.parkUntil()方法。
阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等
待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状
态”则是在等待一段时间,或者唤醒动作的发生。 在程序等待进入同步区域的时候,线程将
进入这种状态。
结束(Terminated):已终止线程的线程状态,线程已经结束执行。
上述5种状态在遇到特定事件发生的时候将会互相转换,它们的转换关系如图12-6所
示。

十六、网络收集资料

①”bump-the-pointer“和“TLABs(Thread-Local Allocation Buffers)”

Bump-the-pointer技术跟踪在伊甸园空间创建的最后一个对象。这个对象会被放在伊甸园空间的顶部。如果之后再需要创建对象,只需要检查伊甸园空间是否有足够的剩余空间。如果有足够的空间,对象就会被创建在伊甸园空间,并且被放置在顶部。这样以来,每次创建新的对象时,只需要检查最后被创建的对象。这将极大地加快内存分配速度。但是,如果我们在多线程的情况下,事情将截然不同。如果想要以线程安全的方式以多线程在伊甸园空间存储对象,不可避免的需要加锁,而这将极大地的影响性能。TLABs 是HotSpot虚拟机针对这一问题的解决方案。该方案为每一个线程在伊甸园空间分配一块独享的空间,这样每个线程只访问他们自己的TLAB空间,再与bump-the-pointer技术结合可以在不加锁的情况下分配内存。
以上是针对新生代空间GC技术的简要介绍,你不需要刻意记住我刚刚提到的两种技术。不知道他们不会对你产生什么影响,但是请务必记住在对象刚刚被创建之后,是保存在伊甸园空间的。那些长期存活的对象会经由幸存者空间转存在老年代空间。

②GC类型

(1)Serial GC (-XX:+UseSerialGC)

GC采取称之为”mark-sweep-compact“的算法。

  1. 算法的第一步是标记老年代中依然存活对象。(标记)
  2. 第二步,从头开始检查堆内存空间,并且只留下依然幸存的对象。(清理)
  3. 最后一步,从头开始,顺序地填满堆内存空间,并且将对内存空间分成两部分:一个保存着对象,另一个空着(压缩)。

总结:因为在桌面单机时代产生,适合于应用在单机桌面系统上。应用在服务器上回降低服务器的性能。单线程(2)Parallel GC (-XX:+UseParallelGC)

parallel GC使用多个线程

(2)Parallel GC (-XX:+UseParallelGC)

parallel GC使用多个线程

(3)Parallel Old GC(-XX:+UseParallelOldGC)

Parallel Old GC在JDK5之后出现。与parallel GC相比,唯一的区别在于针对老年代的GC算法。Parallel Old GC分为三步:标记-汇总-压缩(mark – summary – compaction)。汇总(summary)步骤与清理(sweep)的不同之处在于,其将依然幸存的对象分发到GC预先处理好的不同区域,算法相对清理来说略微复杂一点。

(4)CMS GC (-XX:+UseConcMarkSweepGC)

这种GC类型在拥有stop-the-world时间很短的优点的同时,也有如下缺点:

  •  它会比其他GC类型占用更多的内存和CPU
  •  默认情况下不支持压缩步骤

在使用这个GC类型之前你需要慎重考虑。如果因为内存碎片过多而导致压缩任务不得不执行,那么stop-the-world的时间要比其他任何GC类型都长,你需要考虑压缩任务的发生频率以及执行时间。
(5). G1 GC

还不存在稳定的版本

猜你喜欢

转载自blog.csdn.net/yuwenlanleng/article/details/84672861
今日推荐