Archerda's Blog

Programmer. Meditating.


  • 首页

  • 归档

  • 标签

Tomcat的类加载器架构

发表于 2015-09-10

Web服务器需要解决的几个问题

主流的Java Web服务器,如Tomcat、Jetty、WebLogic、WebSphere或其他没有列举的服务器,都实现了自己定义的类加载器,而且一般还不止一个。因为一个功能健全的Web服务器,要解决如下几个问题:

  • 部署在同一服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。这是最基本的需求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求一个类库在一个服务器中只有一份,服务器应当保证两个应用程序的类库可以互相独立使用。
  • 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以互相共享。这个需求也很常见,例如,用户可能有10个使用Spring组织的应用程序部署在同一台服务器上,如果把10份Spring分别存放在各个应用程序的隔离目录,将会是很大的资源浪费-这主要不是磁盘空间的问题,而是指类库在使用时都要被加载到服务器内存中,如果类库不能共享,虚拟机的方法区就会容易出现过度膨胀的风险。
  • 服务器需要尽可能地保证自身的安全不受部署的Web应用程序影响。目前,有许多主流的Web服务器自身也是使用Java语言来实现的。因此服务器本身也有类库依赖的问题,一般来说,基于安全考虑,服务器所使用的类库应该与应用程序的类库互相独立。
  • 支持JSP应用的Web服务器,大多数都需要支持HotSwap功能。我们知道,JSP文件最终要编译成Java Class才能由虚拟机执行,但JSP文件由于其纯文本存储的特性,运行时修改的概率远远大于第三方类库或程序自身的Class文件。而且ASP、PHP和JSP这些网页应用也把修改后无须重启作为一个很大的“优势”来看待。因此主流的Web服务器都会支持JSP生成类的热替换,当然也有非主流的,如运行在生产模式(Production Mode)下的WebLogic服务器默认就不会处理JSP文件的变化。

由于存在上述问题,在部署Web引用时,单独的一个ClassPath就无法满足需求了,所以各种Web服务器都“不约而同”地提供了好几个ClassPath路径使得用户存放第三方类库,这些路径一般都以“lib”或者“classes”命名。被放置在不同路径中的类库,具备不同的访问范围和服务对象,通常,每一个目录都会有一个相对应的自定义类加载器去加载放置在里面的Java类库。下面我们来看看Tomcat服务器,看看Tomcat具体是如何规划用户类库结构和类加载器的。(ps.这里用的是Tomcat5.x版本,在Tomcat6.x的默认配置下,/common、/server、/shared三个目录已经合并在一起了。)

Tomcat的类库结构

在Tomcat目录结构中,有3组目录/common/*、/server/*、/shared*,可以存在Java类库,另外还可以加上Web应用程序自身的目录/WEB-INF/*,一共4组,把Java类库放置在这些目录中的含义如下。

目录 作用
common 类库对Tomcat和所有的Web应用程序可见
server 类库对Tomcat可见,对Web应用程序不可见
shared 类库对Tomcat不可见,对所有Web应用程序可见
WEB-INF 类库对Tomcat和其他Web应用不可见,只对Web程序本身可见

Tomcat的类加载器架构

为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器,这个类加载器按照经典的双亲委派模式来实现,其关系如下图。

顶层3个类加载器是JDK默认提供的类加载器,这3个加载器的作用在这里有解析JVM-类加载机制.而CommonClassLoader、CatalinaClassLoader、SharedClassLoader、WebAppClassLoader则是Tomcat自定义的类加载器,它们分别加载/common/*、/server/*、/shared/*、/WEB-INF/*中的类库。其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用对应一个WebApp类加载器,每一个Jsp文件对应一个Jsp类加载器。

从上图的委派关系中可以看出,CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,而CatalineClassLoader和SharedClassLoader自己能加载的类则与双方相互隔离。 WebAppClassLoader可以使用ShareClassLoader加载到的类,,但各个WebAppClassLoader实例之间相互隔离。而JasperClassLoader的加载范围仅仅是这个JSP文件所编译出来的那一个Class文件,它出现的目的就是为了被丢弃:当服务器检测到JSP文件被修改的时候,会替换掉目前的JasperClassLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能(那究竟是如何实现的?)。

对于Tomcat的6.x版本,只有指定了tomcat/conf/catalina.properties配置文件的server.loader和shared.loader项后才会真正建立CatalinaClassLoader和SharedClassLoader的实例,否则会用到这两个类的地方都会用CommonClassLoader的实例代替,而默认的配置文件中没有设置这两个项。所以Tomcat6.x顺理成章地把/common、/shared和/server三个目录合并成了一个/lib目录,这个目录里的类库相当于以前的/common目录中类库的作用。这是Tomcat设计团队为了简化大多数的部署场景所做的一项改进,如果默认的设置不能满足需求,用户可以通过修改配置文件指定server.loader和shared.loader的方式重新启动Tomcat5.x的类加载器架构。

一个问题

思考一个问题:前面曾经提到过一个场景,如果有10个Web应用程序都是用Spring来进行组织和管理的话,可以把Spring放在Common或者Shared目录下让这些程序共享。Spring要对用户程序的类进行管理,自然要能访问到用户程序的类,而用户的程序显示是放在/WebApp/WEB-INF目录中的,那么被CommonClassLoader或SharedClassLoader加载的Spring如何访问并不在其加载范围内的用户程序呢?

解答:按照我的理解,类的加载是双亲委派模型,当需要加载Spring时,Tomcat会优先使用WebAppClassLoader去加载Spring,然后会委托父类加载器SharedClassLoader,这时SharedClassLoader加载/shared里面的Spring类库,所以在WebAppClassLoader加载Spring成功。而每个WebAppClassLoader是隔离的,它们加载的Spring互不影响。


JVM 类加载机制

发表于 2015-09-09

概述

虚拟机是如何加载Class文件的?Class文件加载到虚拟机后会发生什么变化?这些问题都牵涉到虚拟机的类加载机制。虚拟机把描述类的数据从Class文件中加载到内存中,并对数据进行校验(为什么要校验?)、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型(怎么样的才能直接使用呢?),这就是虚拟机的类加载机制。

与那些在编译时需要进行连接工作的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的。这种策略虽然会令类加载时稍微增加一些性能开销(这里指定是时间上的开销吧?),但是会为Java程序提供高度的灵活性,Java天生可以动态扩展(怎么算动态扩展?)的语言特性就是依赖运行期间动态加载和动态连接这个特点实现的。


类加载的时机

类从被加载到虚拟机内存中开始使用,到卸载出内存位置,它的整个生命周期如下图。
类的生命周期

上图中,加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始。但是解析阶段却不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(这是什么鬼???)。

什么时候需要开始类加载过程的第一个阶段:加载。Java虚拟机规范中并没有进行强制约束,这点可以由虚拟机自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行”初始化”(而加载、验证、准备自然需要在此之前开始)。

  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:通过子类引用父类的静态字段,不会导致子类初始化。

输出结果:

SuperClass init.
123

类加载列表:

[Loaded test1 from file:]
[Loaded java.lang.Void from /Library/Java/JavaVirtualMachines/jdk1.7.0_79.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded SuperClass from file:]
[Loaded SubClass from file:]
SuperClass init.
123
[Loaded java.lang.Shutdown from /Library/Java/JavaVirtualMachines/jdk1.7.0_79.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Shutdown$Lock from /Library/Java/JavaVirtualMachines/jdk1.7.0_79.jdk/Contents/Home/jre/lib/rt.jar]

分析:

上面的代码之所以没有输出“SubClass init.”,是因为对于静态字段,只有直接定义了这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证,在虚拟机规范中并未规定。对于HotSpot虚拟机来说,可通过-XX:+TraceClassLoading参数来观察此操作会导致子类的加载。

被动引用场景-2:通过数组定义来引用类,不会触发此类的初始化。

输出结果:(无)

类加载列表:

[Loaded test2 from file:]
[Loaded java.lang.Void from /Library/Java/JavaVirtualMachines/jdk1.7.0_79.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded SuperClass from file:]
[Loaded java.lang.Shutdown from /Library/Java/JavaVirtualMachines/jdk1.7.0_79.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Shutdown$Lock from /Library/Java/JavaVirtualMachines/jdk1.7.0_79.jdk/Contents/Home/jre/lib/rt.jar]

分析:

运行之后发现没有输出“SuperClass init.”,说明没有触发类SuperClass的初始化阶段。但是这段代码里面触发了另一个名为”[LSuperClass”的类的初始化阶段,对于用户来说,这并不是一个合法的累名称,它是一个又虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令anewarray触发。这个类代表了一个元素类型为SuperClass的一维数组,数组中应有的属性和方法(用户可直接只用的只有被修饰未public的length属性和clone方法)都实现在这个类里面。Java语言中对数组的访问比C/C++相对安全是因为这个类封装了数组元素的访问方法(准确地说,越界检查不是封装在数组元素访问的类中,而是封装在数组访问的xaload、xastore字节码指令中),而C/C++直接翻译为对数组指针的移动。在java语言中,当检查发生数组越界时会抛出java.lang.ArrayIndexOutOfBoundsException异常。

被动引用场景-3:使用父类常量不会触发定义常量的类的初始化

输出结果:

Hello World.

类加载列表:

[Loaded test from file:]
[Loaded java.lang.Void from /Library/Java/JavaVirtualMachines/jdk1.7.0_79.jdk/Contents/Home/jre/lib/rt.jar]
Hello World.
[Loaded java.lang.Shutdown from /Library/Java/JavaVirtualMachines/jdk1.7.0_79.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Shutdown$Lock from /Library/Java/JavaVirtualMachines/jdk1.7.0_79.jdk/Contents/Home/jre/lib/rt.jar]

分析:

上述代码运行之后,也没有输出“ConstClass init.”,这是因为虽然在Java源码中引用了ConstClass类中的常量HW,但其实在编译阶段通过常量传播优化,已经将此常量的值“Hello World.”存储到test类的常量池中,以后test对常量ConstClass.HW的引用实际都被转化为test类对自身常量池(每个类都有自己的常量池??)的引用了。也就是说,实际上test的Class文件中没有ConstClass类的符号引用入口,这两个类在翻译成Class之后就不存在任何联系了。

PS:接口的加载过程与类加载稍有一些不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点与类是一致的,上面的代码块都是用static {}来输出初始化信息的,而接口中不能使用,但是编译器仍然会为接口生成 <clinit>()类构造器(方法构造器是<init>()),用于初始化接口中所定义的成员变量。
PSS:接口与类真正有所区别的是前面讲述的5种“有且仅有”需要开始初始化的第3种:当一个类在初始化,要求其父类全部都已经初始化过了。但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(比如引用接口中定义的常量)才会初始化。


类加载过程

加载

“加载”是“类加载”(Class Loading)过程的一个阶段。在加载阶段,虚拟机需要完成3件事:

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

虚拟机规范的这3点要求其实不算具体,因此虚拟机实现与具体应用的灵活度都是相当大的。例如“通过类的全限定名来获取此类的二进制字节流”这条,它没有指明二进制字节流要从一个Class文件获取,准确地说根本没有指明要从哪里获取、怎样获取。虚拟机设计团队在加载阶段搭建了一个相当开放的、广阔的“舞台”,许多举足轻重的Java技术都建立在这基础上,例如:

  • 从ZIP包中获取,这很常见,最终成为JAR、EAR、WAR格式的基础。
  • 从WEB中获取,这种场景最典型的应用就是Applet了。
  • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerateProxyClass来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。
  • 由其他文件生成,典型场景就是JSP应用,即由JSP文件生成对应的Class类。
  • 从数据库中读取,这种场景相对少见,例如有些中间件服务器(如SAP Netweaver)可以选择把程序安装到数据库中来完成代码在集群间的分发。
  • ….
    说这么多,其实就是要说明二进制字节流来源多。。。

相对于类加载过程的其他阶段,一个非数组类的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发者可以通过自定义的类加载器去控制字节流的获取方式(也就是重写一个类加载器的loadClass()方法)。

对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(Element Type,指的是数组去掉所有维度的类型)最终是要靠类加载器去创建,一个数组类(下面简称C)创建过程遵循以下规则:

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

关于类加载器,可以查看后续段落。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象作为程序访问方法区中的这些类型数据的外部接口。

加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这2个阶段的开始时间仍然保持这固定的先后顺序。

验证

验证的目的是为了确保Class文件的字节流包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

Java语言本身是相对安全的语言,使用纯碎的Java代码无法做到诸如访问数组边界外的数据、将一个对象转换未它并未实现的类型、跳转到不存在的代码行之类的事情。如果做了,编译器将拒绝编译。但前面说过,Class文件并不一定要求用Java源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写产生Class文件。在字节码语言层面,上述Java代码无法做到的事情都是可以实现的,至少语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机堆自身安全的一项重要工作。

验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载子系统又占了相当大的一部分。《Java虚拟机规范(第二版)》对这个阶段的限制、指导还是比较笼统的,规范中列举了一些Class文件格式中的静态和结构化约束,如果验证到输入的字节流不符合Class文件格式的约束,虚拟机就应该抛出一个java.lang.VerifyError异常或者其子类异常,但具体应当检查哪些方面、如何检查、何时检查,都没有足够具体的要求和明确的说明。直到2011年发布的《Java虚拟机规范(Java SE 7版)》大幅增加了描述验证过程的篇幅(从不足10页到130页),这时约束和验证规则才变得具体起来。下面从整体上看,验证阶段大致会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证

第一阶段验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这个阶段可能包括下面这些验证点:

  • 是否以魔数0xCAFEBABE(为了方便虚拟机识别文件是否是class格式的文件,JVM规定每个class文件都必须以一个word(4个字节)来开始,这个数字就称为魔数)开头。
  • 主、次版本号是否在当前虚拟机的处理范围之内(魔数后续的内容就是一个word的长度来表示生成的class文件的版本号,版本号分为主版本号和次版本号,其中前两个字节表示次版本号,后两个字节表示主版本号,排列的顺序遵从高位在前,低位在后的原则)。
  • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。
  • Class文件中各个部分及文件本身是否有被删除或附加的其他信息。
  • …..

实际上,第一阶段的验证点还远不止这些,上面这些只是从HotSpot虚拟机源码中摘抄的一小部分,该验证阶段的主要是保证输入的字节流能正确地解析并存储于方法区中,格式上符合描述一个Java类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面3个验证阶段全部都是基于方法区的存储结构进行的,不会再直接操作字节流。

元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这个阶段可能包括的验证点如下:

  • 这个类是否有父类(除了java.lang.Object之外,所有类都应当有父类)。
  • 这个类的父类是否抽象了不允许被继承的类(被final修饰的类)。
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段、出现了不符合规则的方法重载比如方法参数都一样但返回值类型不同等)。
  • ……

第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。

字节码验证

第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个int类型的数据,使用时却按long类型来加载如本地变量表中。
  • 保证跳转指令不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,只是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
  • ……

如果类方法体的字节码没有通过字节码验证,那肯定有问题;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。即使字节码验证之中进行了大量的检验,也不能保证这一点,这里涉及了一个离散数学中很著名的问题“Halting Problem”:通俗的说就是,通过程序去校验程序逻辑是无法做到绝对准确的–不能通过程序准确地检查出程序是否能在有限的时间内结束运行。

符号引用验证

最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接第三阶段—解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性检验,通过需要校验一下内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
  • 符号引用中的类、字段、方法的访问性(public、default、protectd、private)是否可以被当前类访问。
  • ……

符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、但不是一定必要的(因为对程序运行期没有影响)阶段。如果所运行的全部代码(包括自己编写的以及第三方的代码)都已经被反复使用和验证过,那么在实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

准备阶段是正式为类变量分配内存并且设置类变量初始值的阶段,这些变量所使用的内存将在方法区进行分配。这个阶段中有2个容易产生混淆的概念需要强调一些,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:

public static int value = 123;

那变量value在准备阶段的初始值是0而不是123,因为这时候尚未开始任何Java方法,而把value赋值为123的putstatic指令是在程序被编译后,存放于类构造器()方法中,所以把value赋值未123的动作将在初始化阶段才会执行。下表是Java中所有基本数据类型的零值。
|数据类型|零值|
|:-|:-|
|byte|(byte)0|
|short|(short)0|
|int|0|
|long|0L|
|float|0.0f|
|double|0.0d|
|char|’\u0000’|
|boolean|false|
|reference|null|

上面提到,在“通常情况”下,初始值是零值,相对的“特殊情况”就是:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就回被初始化为ConstantValue属性所指定的值,假如有:

public static final int value = 123;

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值未123.

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。那解析阶段中的所说的直接引用与符号引用有什么关联呢?

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
  • 直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经存在内存中。

虚拟机规范并未规定解析阶段发生的具体时间,只要求在执行

anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield、putstatic

这16个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。

类或接口解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要一下3个步骤:D(N -> C)

  1. 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或者实现的接口。一旦这个加载过程出现任何异常,解析过程就宣告失败。
  2. 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第1点的规则加载数组元素类型,那么需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
  3. 如果上面的步骤没有任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IIlegalAccessError异常。

字段解析

要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引中的CONSTANT_Class_info符号引用,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口的符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机规范要求按如下步骤对C进行后续字段的搜索。

  1. 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  2. 否则,如果C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符斗鱼目标相匹配的字段,则返回这个字符的直接引用,查找结束。
  3. 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  4. 否则,查找失败,抛出java.lang.NoSuchFieldError异常。

如果成功返回了直接引用,还要对直接引用进行权限验证,如果没有相应的权限,则会抛出java.lang.IIlegalAccessError异常。

类方法解析

类方法解析的第一个步骤与字段解析一样,也需要先解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索。

  1. 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,就直接抛出java.lang.IncompatibleClassChangeError异常。
  2. 如果通过了第1步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,搜索结束。
  3. 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  4. 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时查找结束,抛出java.lang.AbstractMethodError异常。
  5. 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

接口方法解析

接口方法解析的第一个步骤与字段解析一样,也需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索。

  1. 与类方法解析不同,如果在接口方法表现class_index中索引C是个类而不是接口,就直接抛出java.lang.IncompatibleClassChangeError异常。
  2. 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,搜索结束。
  3. 否则,在接口C的父接口中递归查找,直到java.lang.Object类(查找范围会包括Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  4. 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

由于接口中的所有方法默认都是public的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IIlegalAccessError异常。

初始化

类的初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全有虚拟机主导控制。到了初始化阶段,才真正开始执行类中定义的java代码(或者说是字节码)。

在准备阶段,变量已经赋值过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器< clinit >()方法的过程。我们迟点会解析这个方法是如何生成的,but for now,我们先看这个方法执行过程中一些可能会影响程序运行行为的特点和细节,这部分相对比较贴近普通程序开发者。

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(statci{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。如下代码
    1
    2
    3
    4
    5
    6
    7
    8
    public class Test {
    static {
    i = 0; // 给变量赋值可以正常编译通过
    System.out.println(i); // 这句会提示"非法前向引用"错误
    }
    static int = 1;
    }

Test.java:4: 错误: 非法前向引用
System.out.println(i); // 这句会提示”非法向前引用”
^
1 个错误

  • <clinit>()方法与类的构造函数(或者说是实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法肯定是java.lang.Object的。
  • 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的赋值操作。如下代码,字段B的值是2而不是1.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class Test {
    public static void main(String[] args) {
    System.out.println(Sub.B);
    }
    static class Parent {
    public static int A = 1;
    static {
    A = 2;
    }
    }
    static class Sub extends Parent {
    public static int B = A;
    }
    }
  • <clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法并不需要执行父类的<clinit>()方法。只有当使用父接口中定义的变量时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要线程阻塞等待,知道活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

类加载器

虚拟机团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放在Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

类加载器可以说是Java语言创新的一项创新,也是Java语言流行的重要原因之一,它最初是为了满足Java Applet的需求而开发出来的。虽然现在Applet技术基本已经“死亡”,但类加载器却在类层次划分、OSGi、热部署、代码加密等领域大放异彩,成为Java技术体系中一块重要的基石。

类与类加载器

类加载器虽然只用于实现类的加载动作,但是它在Java程序中起到的作用却远远不限于类加载阶段。对于任意类,都需要由它的类加载器和这个类本身一同确定其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类命名空间。这句话可以表达得更通俗一些:比较2个类是否“相等”,只有在这2个类是由同一个类加载器加载的前提下才有意义,否则,即使这2个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这2个类必定不相等。

这里所谓的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果。以下代码演示了不同类加载器堆instanceof关键字运算的结果的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 类加载器与instanceof关键字演示
**/
import java.io.InputStream;
import java.io.IOException;
public class ClassLoaderTest {
public static void main(String[] args) throws Exception{
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = this.getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
byte[] bs = new byte[is.available()];
is.read(bs);
return defineClass(name, bs, 0, bs.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
Object obj = myLoader.loadClass("ClassLoaderTest").newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof ClassLoaderTest);
}
}

输出结果:

class ClassLoaderTest
false

上面代码构造了一个简单的类加载器,尽管很简单,但是很简单,但是对于这个演示还是够用了。它可以加载与自己在同一路径下的Class文件。我们使用这个类加载器去加载了一个名为”ClassLoaderTest”的类,并实例化了这个类的对象。2行输出中,从第1行我们可以看出,这个对象确实是类ClassLoaderTest实例化出来的对象,但从第2行可以发现,这个对象与ClassLoaderTest做所属类型检查的时候却返回了false,这是因为虚拟机中存在了2个ClassLoaderTest类,一个由系统应用程序类加载器加载的,另外一个是由我们自定义的类加载器加载的,虽然都来自同一个Class文件,但依然是2个独立的类,做对象所属类型检查时结果自然为false。

双亲委派模型

从Java虚拟机的角度来讲,只存在2种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++实现(这里指的是HotSpot,想MRP、Maxine等虚拟机,整个虚拟机都是由Java编写的,自然Bootstrap ClassLoader也是由Java而不是C++实现。退一步讲,除了HotSpot以外的2个高性能虚拟机JRockit和J9都有一个代表Bootstrap ClassLoader的Java类存在,但是关键方法的实现还是利用JNI回调到C(注意不是C++)的实现上,这个Bootstrap ClassLoader的实例也无法被用户获取到),是虚拟机自身的一部分;另外一种就是所有其他的类加载器,这些类加载器都Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

从开发人员的角度来看,类加载器还可以分的更细致一些,绝大部分程序都会使用到以下3种系统提供的类加载器。

  • 启动类加载器(Bootstrap ClassLoader):前面介绍过,这个类负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录下也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器的时候,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可。
  • 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$ApplClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。这几种类加载器之间的关系如下图。

上图所示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的BC之外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。

类加载器的双亲委派模型在JDK1.2期间被引入并广泛只用于几乎所有的Java程序中,但它并不是一个强制性的约束模型,而是Java设计者推荐给开发者的一种类加载器实现方式。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,二是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。因此所有的加载请求最终都应该传送到顶层的BC中,只有当父类加载器反馈自己无法完成加载时(它的搜索范围没有找到所需的类),子加载器才会尝试自己去加载。

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar中,无论哪个类加载器要加载这个类,最终都是委派给处于顶层的BC进行加载,因此Object类在程序的各个类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,哪系统将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也会变得一片混乱。可以尝试去编写一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但永远无法加载。

双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法中。

破坏双亲委派模型


总结

本文介绍了类加载过程的“加载”、“验证”、“准备”、“解析”、“初始化”5个阶段中虚拟机进行了哪些操作,也介绍了类加载器的工作原理及其堆虚拟机的意义。


JVM体系结构(图文版)

发表于 2015-09-08

架构图

参考文档

  • JVM 体系结构

HotSpot虚拟机主要参数表

发表于 2015-09-07

摘自《深入理解Java虚拟机-JVM高级特性与最佳实践 第二版》周志明著

虚拟机字节码指令表

发表于 2015-09-07

摘自《深入理解Java虚拟机-JVM高级特性与最佳实践 第二版》周志明著

JVM 垃圾回收算法及垃圾回收器详解

发表于 2015-09-06

简介

Java与C++之间有一堵由内存动态分配和垃圾回收技术所围成的”高墙”,墙外的人想进去,墙里面的人想出来。
— 摘自《深入理解Java虚拟机》

垃圾回收(Garbags Collection,GC),大部分人说起GC,第一反应都是想到Java吧。其实GC技术的历史比Java悠久,1960年诞生于MIT的Lisp是第一门真正使用内存动态分配和垃圾回收技术的语言。GC需要完成的3件事:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

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

垃圾回收算法

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是确定哪些对象还”存活”着,哪些已经”死去”(既不可能再被任何途径使用)。

引用计数算法(Reference Counting)

原理:给对象添加一个引用计数器,每当有一个地方引用它时,计数器的值+1,当引用失效时,计数器的值-1;任何时候计数器未0的对象就是不可能再被使用的。于是引用计数算法通知GC收集器来回收它们。

客观地说,引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,也有一些比较著名的案例,比如微软的COM技术、使用AS3的FlashPlayer、Python语言和在游戏脚本领域被广泛使用的Squirrel中都使用引用计数算法来进行内存管理。但是,主流的JVM里面没有选用引用计数算法来管理内存,其中最重要的原因就是它很难解决对象循环引用的问题。

一个简单的循环引用问题描述如下:有对象A和对象B,对象A中含有对象B的引用,对象B中含有对象A的引用。此时,对象A和对象B的引用计数都不为0。但是在系统中却不存在第三个对象引用了A或B,A和B是应该被回收的垃圾对象,但由于垃圾对象的相互引用,从而使得垃圾回收期无法识别,引起内存泄露。

标记-清除算法(Mark-Sweep)

最基础的回收算法,后续的收集算法都是基于它的思路并对其不足进行改进而得到的。如同它的名字一样,算法分为”标记“和”清除“2个阶段。
标记阶段:在标记阶段首先通过根节点,标记所有从根节点开始的较大对象。因此,未被标记的对象就是未被引用的对象。
清除阶段:回收所有未被标记的对象。
标记-清除算法的执行过程如下图所示。
标记-清除算法的执行过程
然而,它有2个不足的地方:
一个是效率问题,标记和清除两个过程的效率都不高;
二是空间问题,标记清除之后会产生大量不连续空间的内存碎片,空间碎片太多会导致以后在程序运行过程中需要分配较大对象的时候,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法(Coping)

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

现在的商业JVM都采用这种收集算法来回收新生代。 IBM公司的专门研究表明,新生代中的对象98%是”朝生夕死”的,所以不需要按照1:1比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的80%+10%,只有10%的内存会被”浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survior空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

标记-压缩算法(Mark-Compact)

复制收集算法在对象存活率较高的时候进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代不能直接选用这种算法。

根据老年代的特点,”标记-压缩”算法出现了,标记过程依然与”标记-清除”算法一样,但后续步骤不是直接对可回收对象进行回收,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。标记-压缩算法示意图如下。
标记-压缩算法示意图

分代收集算法(Generational Collection)

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

JVM 垃圾回收器分类

如果说回收算法是内存回收的方法论,那么垃圾回收器就是内存回收的具体实现。JVM规范中对垃圾回收器应该如何实现并没有任何规定,因此不同厂商,不同版本的虚拟机所提供的垃圾回收器都可能有很大区别,并且一般都会提供参数供用户根据自己的特点和要求组合出各个年代所使用的回收器。HotSpot虚拟机所包含的所有回收器如图。(G1是在JDK1.7 Update14之后才正式商用,之前都是实验状态。)
HotSpot虚拟机的垃圾回收器

上图展示的7种不同分代的回收器,如果2个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新
生代回收器还是老年代回收器。

在介绍这些收集器各自的特性前,我们需要明确一个观点:虽然我们实在对各个收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到目前为止仍然还没有最好的回收器,更加没有万能的回收器,所以我们选择的只是具体应用场景下最合适的回收器。不然,HotSpot虚拟机就没必要实现那么多不同的回收器了。

Serial回收器

Serial回收器是最基本、发展历史最悠久的回收器。曾经(JDK1.3前)是虚拟机新生代回收的唯一选择。看名字就知道,这个回收器是一个单线程的回收器,但它的”单线程”的意义并不仅仅说明它只会使用一个CPU或者一条回收线程去完成垃圾回收的工作,更重要的是它在进行垃圾回收的过程中,必须暂停所以其他的工作县城,直到它回收结束。”Stop The World”这个名字听起来或许很酷,但这项工作实际上是有虚拟机在后台自动发起和自动完成的,在用户完全不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。Serial/Serial Old回收器的运行过程如下。
Serial/Serial Old回收器运行示意图

写到这里,Serial看起来被描述成了一个”老而无用”的鸡肋了,但实际上到目前为止,它依然是虚拟机运行在Client模式下的默认新生代回收器。它也有优于其他回收器的地方:简单而高效(与其他回收器的单线程比)。

ParNew回收器

ParNew回收器其实就是Serial回收器的多线程版本,除了使用多条线程进行垃圾回收之外,其他行为包括Serial回收器可用的所有控制参数(例如:-XX:SurvivorRation、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailue等)、回收算法、Stop The World、对象分配规则、回收策略等都与Serial回收器完全一样。在实现上,这2中回收器也共用了很多代码。ParNew回收器的工作流程如图。
ParNew回收器的工作流程

ParNew回收器除了多线程回收之外,其他与Serial回收器相比并没有太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的新生代回收器,其中有一个与性能无关但很重要的原因是,除了Serial回收器外,目前只有它能与CMS回收器配合工作。在JDK1.5时期,HotSpot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾回收器-CMS回收器(Concurrent Mark Sweep),这款回收器是HotSpot虚拟机中第一款真正意义上的并发(Concurrent)回收器,它第一次实现了让回收器回收线程和用户线程(基本上)同时工作。

不幸的是,CMS作为老年代的回收器,却无法与JDK1.4中已经存在的新生代回收器Parallel Scavenge回收器配合工作(Parallel Scavenge回收器及后面提到的G1回收器都没有使用传统的GC回收器代码框架,而另外独立实现,其余几种回收器则共用了部分的框架的代码)。所以在JDK 1.5 中使用CMS来回收老年代的时候,新生代只能选择Serial或者ParNew中的一个。ParNew回收器也是使用-XX:+UseConcMarkSweepGC选项后的默认新生代回收器,也可以使用-XX:UseParNewGC选项来强制指定它。

从ParNew回收器开始,后面还会接触到几款并发和并行的回收器,有必要先解释下这2个名词。它们都是并发编程中的概念,在讨论垃圾回收器的上下文语境中,它们可以解释如下:

  • 并行(Parallel):指多条垃圾回收线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程和垃圾回收线程同时执行(但不一定是并行,可能会交替进行),用户程序继续运行,而垃圾回收程序运行在另一个CPU上。

Parallel Scavenge回收器

PS回收器是一个新生代回收器,它也是使用复制算法的回收器,又是并行的多线程回收器….看上去和PerNew回收器都一样,那它们有什么不同之处呢?

PS回收器的特点是它的关注点和其他回收器不同,CMS等回收器的关注点是尽可能缩短垃圾回收时用户线程的等待时间,而PS回收器的目标则是达到一个可控制的吞吐量(Throughput)(=运行用户/(用户线程时间+垃圾回收时间))。

停顿时间越短就越适合需要与用户交互的程序,良好的响应时间能提升用户体验,而高吞吐量则可以高效率利用cpu时间,尽快完成程序的运算任务,主要适合在后台运行而不需要太多交互的任务。

PS回收器提供了2个参数用于精确控制吞吐量,分别是控制最大垃圾回收停顿时间的-XX:MaxGCPauseMillis以及设置吞吐量大小的-XX:GCTimeRatio参数。MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,回收器将尽可能保证内存回收花费的时间不超过设定值。GCTimeRatio参数的值是一个大于0且小于100的数,也就是垃圾回收时间占总时间的比率,相当于吞吐率的倒数,默认值是99,就是允许最大1%(即1/(1+99))的垃圾回收时间。

由于PS回收器与吞吐量关系密切,因此也被称为”吞吐量优先”的回收器。除上述2个参数外,PS回收器还有一个参数:-XX:UseAdaptiveSizePolicy值得关注。这是一个开关参数,当这个参数打开以后,就不需要手动指定新生代的大小-Xmn、Eden与Survivor的比例-XX:SurvivorRadio、晋升老年代对象年龄-XX:PretenureSizeThreshold等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量。这种调节方式成为GC自适应的调节策略(GC Ergonmics)。自适应调节也是PS回收器与PerNew回收器的一个重要区别。

Serial Old 回收器

Serial Old是Serial的回收器的老年代版本,同样是一个单线程回收器,使用”标记-压缩”算法。

这个回收器的主要意义也是在给Client模式下的虚拟机使用。如果在Server模式下,主要有2大用途:一是在JDK 1.5 以及之前的版本中与PS回收器配合使用;二是作为CMS回收器的后备方案,在并发回收发生Concurrent Mode Failure时使用。Serial Old的工作过程如图。

Parallel Old回收器

PO回收器是PS回收器的老年代版本,使用”标记-压缩”算法。这个回收器是在jdk 1.6 才开始提供的,在此之前,新生代的PS回收器一直处于比较尴尬的地位。原因是,如果新生代选择了PS回收器,老年代除了Serial Old回收器外别无选择。由于老年代Serial Old回收器在服务器应用性能上的”拖累”,使用了PS回收器也未必能在整体上获得吞吐量最大化的效果。

直到PO回收器的出现,“吞吐量优先”回收器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑PS+PO回收器。PO回收器的工作流程如下图。

CMS 回收器

CMS(Concurrent Mark Sweep)回收器是一种以获取最短停顿时间为目标的回收器。 目前很大一部分的Java应用集中在互联网或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统的停顿时间最短,以给用户较好的体验。CMS回收器就非常符合这类应用的需求。

从名字就可以看出,CMS回收器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种回收器来说更复杂些。整个过程分为4个步骤,包括:

  • 初始标记CMS initial mark
  • 并发标记CMS concurrent mark
  • 重新标记CMS remark
  • 并发清除CMS concurrent sweep

其中初始标记和重新标记都需要“stop the world”。初始标记仅仅只是标记一下GC Roots能关联到的对象,速度很快;并发标记阶段就是进行GC Roots Tracing的过程;而重新标记则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿会比初始标记稍长,但远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除都可以和用户进程一齐工作,所以总体来说,CMS回收器的内存回收工作是与用户线程一齐并发执行的。由下图可以清楚地看到CMS回收器的运作步骤中并发和需要停顿的时间。

3个明显的缺点:

  1. CMS回收器对CPU资源非常敏感。
  2. CMS回收器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC出现。
  3. 基于“标记-清除”算法带来的内存碎片。

G1 回收器

G1(Garbage-First)是一款面向服务端应用的垃圾回收器,在JDK 1.7的HotSpot虚拟机中正式商用。HotSpot开发团队赋予它的使命是(在比较长期的)未来可以替换掉JDK1.5中发布的CMS垃圾回收器。与其他GC回收器相比,G1具备如下特点:

  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,以此来缩短Stop-The-World停顿时间,部分回收器原本需要停顿Java线程执行的GC操作,G1可以通过并发的方式让Java程序继续运行。
  • 分代回收:与其他回收器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他回收器就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活一段时间、熬过多次GC的旧对象以取得更好的收集效果。
  • 空间整合:与CMS回收器的标记-清理算法不同,G1从整体上看是基于标记-压缩算法实现的回收器,从局部(两个Region之间)上看是基于复制算法实现的,但无论如何,这两种算法都意味着G1运行期间不会产生内存空间碎片,回收后能提供完整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续空间而提前触发下一次GC。
  • 可预测的停顿:这个G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿之外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度未M毫秒的时间片段内,消耗在垃圾回收上的时间不得超过N毫秒,这几乎是实时Java(RTSJ)的垃圾回收器的特征了。

在G1之前的其他回收器进行回收的范围都是整个新生代或者老年代,而G1不再是这样。使用G1回收器时,Java堆的内存布局就与其他回收器有很大区别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

G1回收器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾回收。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的回收时间,优先回收价值大的Region(这也就是Garbage-First名称的由来)。这种使用Region划分内存以及有优先级的区域回收方式,保证了G1回收器在有限的时间内可以获取尽可能高的收集效率。

在G1回收器中,Region之间的对象引用以及其他回收器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查老年代中的对象引用了新生代的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set即可保证不对全堆扫描也不会有遗漏。

如果不计算维护Remembered Set的操作,G1回收器的运作大致可划分为一下几个步骤:

  • 初始标记 Initial Marking
  • 并发标记 Concurrent Marking
  • 最终标记 Final Marking
  • 筛选回收 Live Date Counting and Evacuation

我们可以发现,G1的前几个步骤的运作过程和CMS有很多相似之处。初始标记阶段只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。并发标记阶段是从GC Roots开始对堆中对象进行可达性分析,找出存活对象,这阶段耗时长,但可以与用户程序并发执行。最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动那一部分标记记录,虚拟机将这段时间对象变化记录到线程Remembered Set Logs中。然后需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿操作,但是可以并发执行。最后在筛选阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来指定回收计划,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高效率。G1回收器的运作步骤中并发和需要的停顿的阶段如图:

垃圾回收的时机

通过上面的学习,我们已经堆垃圾回收机制有了一个比较全面的了解。下面堆垃圾回收的时机总结一下。

  • 对象优先分配在Eden区,当Eden区没有足够的空间时,虚拟机将发生一次Minor GC。因为大部分在Eden区的对象都是“朝生夕亡”,所以Minor GC执行非常频繁,而且速度也很快。
  • 当老年代没有足够的空间时及时发生Full GC,发生Full GC的时候一般都会有一次Minor GC。大对象直接进入老年代,如很长的字符串数组,虚拟机提供一个-XX:PretenureSizeThreadhold来使得大于这个参数值的对象直接在老年代中分配内存,避免在Eden区和Survivor区发生大量的内存拷贝。
  • 发生Minor GC的时候,虚拟机会检测之前每次晋升到老年代的平均大小是否大于当前老年代的剩余大小,如果大于,说明老年代可能不够空间,所以会先执行一次Full GC;如果小于,则查看HandlePromotionFailure是否允许担保失败,如果允许则只进行Minor GC,如果不允许,则改为进行一次Full GC。

垃圾回收器参数总结

HotSpot虚拟机的垃圾回收器

参数 描述
UseSerialGC 虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial+Serial Old的回收器组合进行内存回收。
UseParNewGC 打开此开关后,使用ParNew+CMS+Serial Old的回收器组合进行内存回收。
UseConcMarkSweepGC 打开此开关后,使用ParNew+CMS+Serial Old组合进行内存回收。Serial Old 讲作为CMS出现Concurrent Mode Failure失败后的后备回收器。
UseParallelGC 虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge+Serial Old组合。
UseParallelOldGC 打开此开关,使用Parallel Scavenge+Parallel Old组合。
SurvivorRation 新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Survivor=8:1。
PretenureSizeThreshold 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配。
MaxTenuringThreshold 直接晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC之后,年龄就+1,当超过这个参数值就进入老年代。
UseAdaptiveSizePolicy 动态调整Java堆中各个区域的大小以及进入老年代的年龄。
HandlePromotionFailure 是否允许分配担保失败,即老年代的剩余空间不足以应对新生代的整个Eden和Survivor区的所有对象都存活的极端情况。
ParallelGCThreads 设置并行GC时进行内存回收的线程数。
GCTimeRatio GC时间占总时间的比率,默认值是99,即允许1%的GC时间。仅在使用Parallel Scavenge回收器生效。
MaxGCPauseMillis 设置GC的最大停顿时间。仅在使用Parallel Scavenge回收器时生效。
CMSInitiatingOccupancyFraction 设置CMS回收器在老年代空间被使用多少后触发GC。默认值为68%,仅在使用CMS回收器生效。
UseCMSCompactAtFullCollection 设置CMS回收器在完成GC后是否要进行一次内存碎片整理。仅在使用CMS生效。
CMSFullGCBeforeCompaction 设置CMS回收器在进行若干次GC后再启动一次内存碎片整理。仅在使用CMS生效。

参考文档

  • 深入理解Java虚拟机 第二版 周志明著
  • JVM 垃圾回收器工作原理及使用实例介绍

Java发展历程(图文版)

发表于 2015-08-31

Java发展历程


参考文档

  • The History of the Java Programming Language (Infographic)

Java 内存模型

发表于 2015-08-28

Java虚拟机规范中试图定义一种Java内存模型(Java Memony Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。经过长时间的验证和修补,在JDK1.5(实现了JSR-133)发布后,Java内存模型已经成熟和完善起来。

主内存和工作内存

Java内存模型规定了所有的变量都存储在主内存(Main Memony)中。每条线程还有自己的工作内存(Working Memony),线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在(读取、赋值等)工作内存中进行,而不能直接读写主内存的变量(volatile变量也一样)。不同线程之间也无法访问对方工作内存中的变量,线程间变量值的传递主要是通过主内存来完成。线程、主内存、工作内存交互图如下。

这里所说的主内存、工作内存与之前JVM 内存区域所说的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的。如果要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈的部分区域。从更低层次来说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

内存间交互操作

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

  • lock(锁定):

    作用于主内存的变量,把一个变量标志为一条线程独占的状态。

  • unlock(解锁):

    作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取):

    作用于主内存的变量,把一个变量的值从主内存中传输到线程的工作内存中,以便随后的load动作使用。

  • load(载入):

    作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use(使用):

    作用于工作内存的变量,把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码时将会执行这个操作。

  • assign(赋值):

    作用于工作内存的变量,把一个从执行引擎接受到的值赋值给工作内存中变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store(存储):

    作用于工作内存的变量,把工作内存中的一个变量传送到主内存中,以便随后的write操作使用。

  • write(写入):

    作用于主内存的变量,把store操作从工作内存中的得到的变量的值放入主内存的变量中。

如果要把一个变量从主内存拷贝到工作内存中,那就要顺序地执行read和load操作,如果要把工作内存中的变量同步回主内存,就要顺序的执行store和write操作。注意,Java内存模型只要求上述2个操作必须顺序执行,而没有保证是连续执行。也就是说,read和load之间、store和write之间是可以插入其他指令的。除此之外,Java内存模型还规定了在执行上述8中基本操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现。即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起了回写但主内存不接受的情况出现。
  • 不允许一个线程丢弃它最近的assign操作。即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存回写到主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。换句话说,就是对一个变量执行use、store操作之前,必须执行过了assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量lock操作,那么会清空工作内存中此变量的值,在执行引擎使用这个变量的时候,必须重新load或assign来初始化该变量。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它进行unlock,也不允许unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock之前,必须先把变量同步回主内存(执行store、write)。

对于volatile型变量的特殊规则

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,但是并不容易理解。

当一个变量定义为volatile时,它将具备2种特性:第一是保证此变量对所有线程的可见性,这里的可见性是指一条线程修改了这个变量的值,新值对于其他线程来说是立即得知的。而普通变量做不到这点,普通变量的值在线程间传递需要通过主内存来完成。“volatile变量对所有线程是立即可见的,对volatile变量的所有写操作都能立刻反应到其他线程中,换句话说,volatile变量在各个线程中是一致的,但是基于volatile变量的运算在并发下却不一定安全”。
由于volatile变量只保证可见性,在不符合一下2条规则的运算场景中,我们仍然要通过加锁(synchronized或JUC中的原子类)来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能确保只有单一的线程改变变量的值;
  • 变量不需要与其他状态变量共同参与不变约束;
  • 在访问变量时不需要加锁;

第二是禁止指令重排序。普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到指点,这就是Java内存模型描述的所谓的“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)

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

JMM要求lock、unlock等8个操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机讲没有被volatile修饰的64位数据的读写规则划分为2次32位的操作来进行,即允许虚拟机实现选择不保证64位数据类型的laod、store、read、write这4个操作的原子性,这点就是所谓的long和double的非原子协定。

如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对他们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。

不过这种读取到“半个变量”的情况非常罕见(在目前商用Java虚拟机中不会楚翔),因为JMM虽然允许虚拟机不把long和double变量的读写实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这样做。在实际开发中,目前各平台下商用虚拟机几乎都选择把64位数据类型的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要把用到的long和double声明为volatile。

原子性、可见性、有序性

JMM是围绕在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。我们逐一看下这3个特性。

原子性(Atomicity):

有JMM来直接保证的原子性变量操作包括read、load、assign、use、store和write,我们大致可以认为基本数据的访问读写是具有原子性的。

如果应用场景需要一个更大范围的原子性保证(经常遇到),JMM还提供了lock和unlock操作来满足这种需求,尽管虚拟机还没有把lock和unlock操作直接开发给开发者使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式使用者2个操作,这2个字节码指令反应到Java代码中就是同步块-synchronized关键字,因此在synchronized块之间的操作也具备原子性。

可见性(Visibility):

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。volatile变量能够保证新值能立即同步到主内存中,以及每次使用前立即从主内存刷新。因此可以说volatile保证了多线程操作时变量的可见性。

除了volatile之外,Java还有2个关键字能够实现可见性,即synchronized和final。同步块的可见性是由“对一个变量执行unlock之前,必须先把此变量同步回主内存中”这条规则获得的。而final关键字可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。

有序性(Ordering):

Java程序中天然的有序性可以归结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行化的语义”,后半句是指“指令重排序”现象和“工作内存和主内存同步延迟”现象。

Java语言提供了volatile和synchronized来保证线程之间操作的有序性,volatile本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的2个同步块只能串行的进入。

呵呵,synchronized真是万能啊,所有3个特性都能由它来保证。的确,大部分并发控制操作都能使用synchronized来完成,也就间接造成了滥用,越“万能”的并发控制,也伴随了越大的性能影响。

先行发生原则

如果JMM中所有的有序性都仅仅靠volatile和synchronized来完成,那么有一些操作将会变得很繁琐,但是我们在编写Java并发代码的时候并没有感觉到这一点,这是因为Java语言中有一个“先行发生”(happens-before)原则。这个原则非常重要,他是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个规则,我们可以通过几条规则一揽子解决并发环境下2个操作之间是否可能存在冲突的素有问题。

下面是JMM下一些“天然的”先行发生关系,这些先行发生关系无需任何同步器协助就已经存在,可以在编码中直接使用。如果2个操作之间的关系不在此列,并且无法从下列规则推到出来,他们的顺序就没有顺序性的保障,虚拟机可以对他们随意地进行重排序。

  • 程序次序规则(Program Order Rule):

    在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。

  • 管程锁定规则(Monitor Lock Rule):

    一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”同样指的是时间上的先后顺序。

  • volatile变量规则(Volatile Variable Rule):

    对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的后面同时指的是时间上得先后顺序。

  • 线程启动规则(Thread Start Rule):

    Thread对象的start()方法先行发生于此线程的每一个动作。

  • 线程终止规则(Thread Termination Rule):

    线程中所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行了。

  • 线程中断规则(Thread Interruption Rule):

    对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断时间的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

  • 对象终结规则(Finallizer Rule):

    一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

  • 传递性(Transitivity):

    如果操作A先行发生于操作B,操作B先行发生于操作C,那么可以推断出操作A先行发生于操作C的结论。

以上。


参考文档

  • 《深入理解Java虚拟机-JVM高级特性与最佳实践》第二版 周志明著

JVM 内存区域

发表于 2015-08-28

对于Java来说,在JVM自动内存管理机制的帮助下,不再需要为每一个new操作去手动管理内存空间,不容易出现内存泄露和内存溢出的问题,由JVM管理内存这一切看上去很美好,不过也正是因为Java把内存控制的权利交给了JVM,一旦出现内存泄露和溢出方面的问题,如果不了解JVM是如何使用内存的,那么排查错误将会成为一个异常艰难的工作。所以了解JVM内存区域是非常有用且必要的。


JVM在执行Java程序的过程中会把它管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,各自的生命周期,有的区域岁JVM进程的启动而存在,有些区域则依赖用户线程的启动和结束而创建和销毁。根据《Java虚拟机规范(Java SE 7版)》的规定,JVM所管理的内存将会包括以下几个运行时区域,如下图所示。

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行字节码的行号指示器。由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的。在任何一个时刻,一个处理器(对多核来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的PC,各条线程之间的PC互不影响,独立存储,这种类型的内存区域也成为线程私有的内存,线程间不可见。

另外,如果线程正在执行的是一个Java方法,这个PC记录的是正在执行的JVM字节码指令的地址;如果正在执行的是Native方法,那么这个PC则置空(Undefined)。此内存区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。

虚拟机栈


与PC一样,JVM栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时会创建一个帧栈(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到执行完成的过程,对应着一个帧栈在虚拟机栈中进栈到出栈的过程。

网上有人把Java内存区分为堆内存(Heap)和栈内存(Stack),这种划分方式比较简单,Java内存区域的划分实际上远比这复杂。这种划分方式的流行只能说明大部分程序员最关注的、与对象内存分配关系最密切的内存区是这2块。其中所指的“堆”会在后面解释,而所指的“栈”就是现在所说的虚拟机栈,或者说是虚拟机栈中局部变量的部分。

局部变量表存放了编译器可知的各种基本数据类型(byte、short、int、long、float、double、boolean、char)、对象引用(Reference类型,它不等同于对象本身,可能是一个对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向一条指令码指令的地址)。

其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot)(在多线程环境中,long和double是比较特殊的,很多操作或许会跟其他基本类型不一样,可能会引起额外的问题,这里先提及一下),其余的数据类型只占用1个Slot。局部变量表所需的空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

在Java虚拟机规范中,对这个区域规定了2中异常状况:如果线程请求的栈深度大于虚拟机允许的深度,将会抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前的大部分的Java虚拟机都可以动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展无法申请到足够的内存,就会抛出OutOfMemoryError异常。

本地方法栈

本地方法栈(Native Method Stack)与虚拟机所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

Java堆

对于大多数应用来说,Java堆(Java Heap)是Java虚拟机过管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在JVM启动时创建。 此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配内存(The heap is the runtime data area from which memory for all class instances and arrays is allocated)。但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在对上也逐渐变得不是那么“绝对”了。

Java堆是GC管理的主要区域,因此很多时候也被称作“GC堆”。从内存回收的角度看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步的划分的目的是为了更好地回收内存,或者更快地分配内存。

根据JLS的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的。不过当前主流的JVM都是按照可扩展来实现的(通过-Xmx和-Xms控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被JVM加载的类信息、常量、静态变量、及时编译器编译后的代码等数据。虽然JVM规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Head(非堆),目的应该是与Java堆区分开来。

对于习惯在HotSpot JVM上开发、部署程序的开发者来说,很多人愿意把方法区成为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot JVM的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。对于其他JVM如BEA JRockit、IBM J9等,是不存在永久代的概念的。原则上,如何实现方法区属于虚拟机实现细节,不受虚拟机规范约束,但使用永久代来实现方法区,现在看来并不是一个好主意,因为这样更容易遇到内存溢出问题(永久代有-XX:MaxPermSize的上限,J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4G,就不会出现问题),而且有极少数方法(例如String.intern())会因为这个原因导致不同虚拟机下有不同的表现。因此,对于HotSpot虚拟机,根据官方发布的路线图信息,现在也有放弃永久代并逐步改为Native Memory来实现方法区的规划了,在目前已经发布的JDK1.7的Hot Spot中,已经把原本放在永久代中的字符串常量池异常。而在目前最新的JDK1.8的Hot Spot,已经移除了永久代了。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

Java虚拟机对Class文件每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java虚拟机规范没有任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过,一般来说,除了保存Class文件中的描述的符号引用外,还会把翻译出来的直接引用也存储到运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有在编译器才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多得便是String类的intern()方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时便会抛出OutOfMemoryError异常。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以说明下。

在JDK1.4中新加入了NIO类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用来进行操作。这样能在一些场合显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

显然,本机直接分配内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是受到本机总内存(包括RAM和SWAP区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。


参考文档

  • 《深入理解Java虚拟机-JVM高级特性与最佳实践》第二版 周志明著

Linxu&Mac 常用命令

发表于 2015-08-25

文件/文件夹/磁盘管理

  • 由第一行开始显示档案内容(n为显示行号):cat [-n] <file-name>
  • 从最后一行开始显示(Mac无):tac <file-name>
  • 查看头几行:head [-lineNumber] <file-name>
  • 查看尾几行:tail [-lineNumber] <file-name>
  • 以二进制的方式读取档案内容:od <file-name>
  • 分页显示文件(space下一页,b上一页):more <file-name>
  • 分页显示文件(支持PageDown和PageUp):less <file-name>
  • 创建软链接(inode不一样): ln -s <from-file-with-absolute-path> <soft-link-file-with-absolute-path>
  • 创建硬链接(inode一样): ln <from-file-with-absolute-path> <hard-link-file-with-absolute-path>
  • 查看文件的inode信息: stat <file>
  • 查看硬盘分区的inode总数和已经使用的数量: df -i
  • 查看每个inode节点的大小: sudo dumpe2fs -h /dev/hda | grep "Inode size"
  • 查看文件的inode号码: ls -i <file>
  • 查看当前文件夹磁盘占用情况:du -shc *
  • 查看磁盘使用情况:df -lh
  • 查看文件或文件夹挂载在哪个分区: df <file-or-directory> -kh
  • 查看inode使用情况: df -hi
  • 找某个文件并打印内容:find . -name "<file-name>" -exec cat {} \;
  • 在当前目录下查找某个字符串并打印行号:find ./* | xargs grep -n '<string-you-find>'

压缩文件管理

  • 创建tar文件:
    tar -cvf <archive-name.tar> <file1-OR-file2-OR-both-to-archive>
  • 查看tar文件:tar -tvf <archive-to-view.tar>
  • 提取tar文件:tar -xvf <archive-to-extract.tar>
  • 创建和提取gzip压缩文件:gzip <filename>
  • 对gzip文档进行解压:unzip <archive-to-extract.zip>
  • 查看gzip文件:unzip -l <archive-to-extract.zip>

计划任务

  • 启动crond服务:service crond start
  • 停止crond服务:service crond stop
  • 重启crond服务:service crond restart
  • 为某个使用者建立/移除crontab任务: crontab -u username
  • 编辑crontab任务内容:crontab -e
  • 查看crontab任务内容:crontab -l
  • 移除crontab所有任务:crontab -r
  • crontab内容格式:
    • * * * * * path
    • 前面5个 * 代表:分(0-59)、时(0-23)、日(1-31)、月(1-12)、周(0-7)
    • * 号代表任何时间都能接受的意思,任意;
    • 如果是一段时间,用-连接
    • 如果是隔开几个时间,用,连接
    • 如果是某个时间单位每隔多久,用/<interval>连接
  • 例如:
    * */12 * * * /Users/Archerda/Configuration/Script/CodingBit/coding.sh
    代表每隔12个小时执行一次coding.sh
  • Mac下的cron日志会在/var/mail/<username>中记录,可以用cat查看。每次cron任务有标准输出时都会记录在该文件中,并且在终端会提醒You have new mail in /var/mail/<username>

用户管理

  • 列出当前用户名:who
  • 查看用户登录状态:w
  • 快速查找某用户信息:finger <user-name>
  • 切换用户(switch user):su <user-name>
  • 修改用户密码:passwd
  • 查看UID等:id <user-name>

系统相关

  • 显示系统内核信息:uname -a
  • 显示发行版信息: cat /etc/issue
  • 查看文件系统块大小:blockdev --getbsz <partion> 比如blockdev --getbsz /dev/sda1

进程管理

  • 查看系统资源使用情况并排序:top
  • 可视化查看系统资源使用情况:sudo htop
  • 使用homebrew安装htop:brew intall htop
  • 列出活跃进程:ps
  • 列出所有系统运行命令并分页:ps -A | less
  • 列出指定进程名的进程(BSD格式输出:USER , PID , %CPU , %MEM , VSZ , RSS , TTY , STAT , START , TIME , COMMAND):ps aux | grep <pname> --color=auto
  • 列出指定进程名的进程(标准格式输出:UID , PID , PPID , C , STIME , TTY , TIME , CMD):ps -ef | grep <pname> --color=auto
  • 根据ID杀死进程:kill <pid>
  • 根据关键字查询PID:pgrep -f <key-word>
  • 根据进程名称杀死一个进程:pkill <name> killall <name>
  • 杀死图形界面程序(Mac默认没有这个工具):xkill
  • 改变线程nice值(优先级,-19最高,19最低,0默认):renice <nice-value> <pid>
  • 在后台不挂断地运行命令(日志默认写在当前目录的nohup.out):nohup <command> <arg> [> <filename> 2>&1] &
  • 建立一个处于断开模式下的会话:screen -dmS <session-name>
  • 列出所有会话:screen -list
  • 重新连接会话:screen -r <session-name>
  • 跟踪进程执行时的系统调用和所接收的信号 (在调试的时候一般是从后往前看strace命令的结果,这样更容易找到有价值的信息): strace <command>
  • Linux查看PID的详细信息:ll /proc/<PID>
    • cwd符号链接的是进程运行目录;
    • exe符号连接就是执行程序的绝对路径;
    • cmdline就是程序运行时输入的命令行命令;
    • environ记录了进程运行时的环境变量;

加密解密

  • 查看文件的MD5(Mac):md5 <file-name>
  • 查看文件的SHA1(Mac): shasum -a 1 <file-name>
  • 查看文件的SHA256(Mac): shasum -a 256 <file-name>
  • 查看文件的MD5(Linux):md5sum <file-name>
  • 查看文件的SHA:shasum <file-name>
  • 查看文件的SHA1:sha1sum <file-name>
  • 生成RSA的SSH: ssh-keygen -t rsa

重定向

  • 输出重定向到剪贴板(Mac): <command> | pbcopy

远程登录

  • ssh登录:ssh [-p <port>] <user>@<host>'
  • ssh免密码登录(公钥就是一段字符串,只要把它追加在authorized_keys文件的末尾):ssh <user>@<host> 'mkdir -p .ssh && cat >> .ssh/authorized_keys' < ~/.ssh/id_rsa.pub
  • ssh禁用密码登录并启用ssh密钥登录:vim /etc/ssh/sshd_config PubkeyAuthentication yes;AuthorizedKeysFile .ssh/authorized_keys;PasswordAuthentication no service sshd restart

CPU管理

  • 显示CPU信息:cat /proc/cpuinfo
  • 显示CPU信息:lscpu
  • 显示CPU占用最多的前10个进程:ps auxw|head -1;ps auxw|sort -rn -k3|head -10

内存管理

  • 显示内存信息:free -m
  • 显示内存信息:cat /proc/meminfo
  • 查看内存页大小:getconf PAGESIZE
  • 查看swap的使用情况:cat /proc/swaps
  • 显示内存消耗最多的前10个进程:ps auxw|head -1;ps auxw|sort -rn -k4|head -10
  • 显示虚拟内存使用最多的前10个进程:ps auxw|head -1;ps auxw|sort -rn -k5|head -10
  • 释放buffer和cache: echo 3 > /proc/sys/vm/drop_caches
  • 每隔一秒高亮显示内存变化情况: watch -n 1 -d "free"

网络管理

  • 查看已用端口:netstat -pltn
  • 查看某个端口被哪个进程占用: lsof -i:<port>
  • 显示机器所属域名: hostname -d
  • 显示完整的主机名和域名: hostname –f
  • 显示当前机器的ip地址: hostname –i
  • 互动式地查询域名记录: nslookup
  • 查询域名对应的IP: dig <domain-name>
  • 查询IP对应的域名: dig -x <ip>
  • 查看网络是否联通: ping <ip|domain-name>
  • 查看公网IP: curl http://members.3322.org/dyndns/getip
  • 每隔一秒高亮显示网络链接数: watch -n 1 -d "netstat -ant"
  • 查看一下当前的网络连接情况: netstat -nt | awk '/^tcp/ {++state[$NF]} END {for(key in state) print key,"t",state[key]}'
  • telnet: telnet <ip> <port>
  • 查看iptables防火墙状态: service iptables status
  • 立即开启iptables防火墙: service iptables start
  • 重启后开启iptables防火墙: chkconfig iptables on
  • 立即关闭iptables防火墙: service iptables stop
  • 重启后关闭iptables防火墙: chkconfig iptables off
  • 立即重启iptables防火墙: service iptables restart

快捷键

  • 光标回到行首:ctrl + a(ahead)
  • 光标回到行尾:ctrl + e(end)
  • 光标向行首移动一个字符:ctrl + b(backwards)
  • 光标向行尾移动一个字符:ctrl + f(forwards)
  • 删除光标到行首的字符:ctrl + w
  • 删除光标到行尾的字符:ctrl + k
  • 删除整个命令行文本字符:ctrl + u
  • 向行首删除一个字符:ctrl + h
  • 向行尾删除一个字符:ctrl + d
  • 粘贴ctrl+u,ctrl+k,ctrl+w删除的字符:ctrl + y
  • 上一个使用的历史命令:ctrl + p
  • 下一个使用的历史命令:ctrl + n
  • 快速检索历史命令:ctrl + r
  • 交换光标所在和其前的字符:ctrl + t
  • 使终端静止,停止输出:ctrl + s
  • 退出ctrl+q引起的静止:ctrl + q
  • 使正在运行的任务运行于后台:ctrl + z
  • 空命令行状态下退出终端:ctrl + d
  • 显示所有终端支持的命令:esc + esc + esc

参考文档

  • SSH原理与运用(一):远程登录
  • SSH原理与运用(二):远程操作与端口转发
  • linux下查看最消耗CPU、内存的进程
1…5678
archerda

archerda

71 日志
37 标签
GitHub Email
© 2015 - 2019 archerda
由 Hexo 强力驱动
主题 - NexT.Muse