Java Classloader机制解析

做Java开发,对于ClassLoader的机制是必须要熟悉的基础知识,本文针对Java ClassLoader的机制做一个简要的总结。因为不同的JVM的实现不同,本文所描述的内容均只限于Hotspot Jvm.

本文将会从JDK默认的提供的ClassLoader,双亲委托模型,如何自定义ClassLoader以及Java中打破双亲委托机制的场景四个方面入手去讨论和总结一下。

JDK默认ClassLoader

JDK 默认提供了如下几种ClassLoader

  1. Bootstrp loader
    Bootstrp加载器是用C++语言写的,它是在Java虚拟机启动后初始化的,它主要负责加载%JAVA_HOME%/jre/lib,-Xbootclasspath参数指定的路径以及%JAVA_HOME%/jre/classes中的类。
  1. ExtClassLoader 
    Bootstrp loader加载ExtClassLoader,并且将ExtClassLoader的父加载器设置为Bootstrp loader.ExtClassLoader是用Java写的,具体来说就是sun.misc.Launcher$ExtClassLoader,ExtClassLoader主要加载%JAVA_HOME%/jre/lib/ext,此路径下的所有classes目录以及java.ext.dirs系统变量指定的路径中类库。

  2. AppClassLoader 
    Bootstrp loader加载完ExtClassLoader后,就会加载AppClassLoader,并且将AppClassLoader的父加载器指定为ExtClassLoader。AppClassLoader也是用Java写成的,它的实现类是sun.misc.Launcher$AppClassLoader,另外我们知道ClassLoader中有个getSystemClassLoader方法,此方法返回的正是AppclassLoader.AppClassLoader主要负责加载classpath所指定的位置的类或者是jar文档,它也是Java程序默认的类加载器。

综上所述,它们之间的关系可以通过下图形象的描述:Java Classloader机制解析_第1张图片

双亲委托模型

Java中ClassLoader的加载采用了双亲委托机制,采用双亲委托机制加载类的时候采用如下的几个步骤:

  1. 当前ClassLoader首先从自己已经加载的类中查询是否此类已经加载,如果已经加载则直接返回原来已经加载的类。

    每个类加载器都有自己的加载缓存,当一个类被加载了以后就会放入缓存,等下次加载的时候就可以直接返回了。

  2. 当前classLoader的缓存中没有找到被加载的类的时候,委托父类加载器去加载,父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到bootstrp ClassLoader.
  3. 当所有的父类加载器都没有加载的时候,再由当前的类加载器加载,并将其放入它自己的缓存中,以便下次有加载请求的时候直接返回。

说到这里大家可能会想,Java为什么要采用这样的委托机制?理解这个问题,我们引入另外一个关于Classloader的概念“命名空间”,它是指要确定某一个类,需要类的全限定名以及加载此类的ClassLoader来共同确定。也就是说即使两个类的全限定名是相同的,但是因为不同的ClassLoader加载了此类,那么在JVM中它是不同的类。明白了命名空间以后,我们再来看看委托模型。采用了委托模型以后加大了不同的ClassLoader的交互能力,比如上面说的,我们JDK本生提供的类库,比如hashmap,linkedlist等等,这些类由bootstrp类加载器加载了以后,无论你程序中有多少个类加载器,那么这些类其实都是可以共享的,这样就避免了不同的类加载器加载了同样名字的不同类以后造成混乱。

如何自定义ClassLoader

Java除了上面所说的默认提供的classloader以外,它还容许应用程序可以自定义classloader,那么要想自定义classloader我们需要通过继承java.lang.ClassLoader来实现,接下来我们就来看看再自定义Classloader的时候,我们需要注意的几个重要的方法:

1.loadClass 方法

loadClass method declare
1 2
public Class<?> loadClass(String name)  throws ClassNotFoundException 

上面是loadClass方法的原型声明,上面所说的双亲委托机制的实现其实就实在此方法中实现的。下面我们就来看看此方法的代码来看看它到底如何实现双亲委托的。

loadClass method implement
1 2 3
public Class<?> loadClass(String name) throws ClassNotFoundException {  return loadClass(name, false); } 

从上面可以看出loadClass方法调用了loadcClass(name,false)方法,那么接下来我们再来看看另外一个loadClass方法的实现。

Class loadClass(String name, boolean resolve)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
protected synchronized Class<?> loadClass(String name, boolean resolve)  throws ClassNotFoundException  {  // First, check if the class has already been loaded  Class c = findLoadedClass(name);//检查class是否已经被加载过了  if (c == null) {  try {  if (parent != null) {  c = parent.loadClass(name, false); //如果没有被加载,且指定了父类加载器,则委托父加载器加载。  } else {  c = findBootstrapClass0(name);//如果没有父类加载器,则委托bootstrap加载器加载  }  } catch (ClassNotFoundException e) {  // If still not found, then invoke findClass in order  // to find the class.  c = findClass(name);//如果父类加载没有加载到,则通过自己的findClass来加载。  }  }  if (resolve) {  resolveClass(c);  }  return c; } 

上面的代码,我加了注释通过注释可以清晰看出loadClass的双亲委托机制是如何工作的。 这里我们需要注意一点就是public Class<?> loadClass(String name) throws ClassNotFoundException没有被标记为final,也就意味着我们是可以override这个方法的,也就是说双亲委托机制是可以打破的。另外上面注意到有个findClass方法,接下来我们就来说说这个方法到底是搞末子的。

2.findClass

我们查看java.lang.ClassLoader的源代码,我们发现findClass的实现如下:

1 2 3
 protected Class<?> findClass(String name) throws ClassNotFoundException {  throw new ClassNotFoundException(name); } 

我们可以看出此方法默认的实现是直接抛出异常,其实这个方法就是留给我们应用程序来override的。那么具体的实现就看你的实现逻辑了,你可以从磁盘读取,也可以从网络上获取class文件的字节流,获取class二进制了以后就可以交给defineClass来实现进一步的加载。defineClass我们再下面再来描述。 ok,通过上面的分析,我们可以得出如下结论:

我们在写自己的ClassLoader的时候,如果想遵循双亲委托机制,则只需要override findClass.

3.defineClass

我们首先还是来看看defineClass的源码:

defineClass
1 2 3 4
protected final Class<?> defineClass(String name, byte[] b, int off, int len)  throws ClassFormatError{  return defineClass(name, b, off, len, null); } 

从上面的代码我们看出此方法被定义为了final,这也就意味着此方法不能被Override,其实这也是jvm留给我们的唯一的入口,通过这个唯一的入口,jvm保证了类文件必须符合Java虚拟机规范规定的类的定义。此方法最后会调用native的方法来实现真正的类的加载工作。

Ok,通过上面的描述,我们来思考下面一个问题:
假如我们自己写了一个java.lang.String的类,我们是否可以替换调JDK本身的类?

答案是否定的。我们不能实现。为什么呢?我看很多网上解释是说双亲委托机制解决这个问题,其实不是非常的准确。因为双亲委托机制是可以打破的,你完全可以自己写一个classLoader来加载自己写的java.lang.String类,但是你会发现也不会加载成功,具体就是因为针对java.*开头的类,jvm的实现中已经保证了必须由bootstrp来加载。

不遵循“双亲委托机制”的场景

上面说了双亲委托机制主要是为了实现不同的ClassLoader之间加载的类的交互问题,被大家公用的类就交由父加载器去加载,但是Java中确实也存在父类加载器加载的类需要用到子加载器加载的类的情况。下面我们就来说说这种情况的发生。

Java中有一个SPI(Service Provider Interface)标准,使用了SPI的库,比如JDBC,JNDI等,我们都知道JDBC需要第三方提供的驱动才可以,而驱动的jar包是放在我们应用程序本身的classpath的,而jdbc 本身的api是jdk提供的一部分,它已经被bootstrp加载了,那第三方厂商提供的实现类怎么加载呢?这里面JAVA引入了线程上下文类加载的概念,线程类加载器默认会从父线程继承,如果没有指定的话,默认就是系统类加载器(AppClassLoader),这样的话当加载第三方驱动的时候,就可以通过线程的上下文类加载器来加载。
另外为了实现更灵活的类加载器OSGI以及一些Java app server也打破了双亲委托机制。

Java虚拟机规范规定JVM的内存分为了好几块,比如堆,栈,程序计数器,方法区等,而Hotspot jvm的实现中,将堆内存分为了三部分,新生代,老年代,持久带,其中持久带实现了规范中规定的方法区,而内存模型中不同的部分都会出现相应的OOM错误,接下来我们就分开来讨论一下。

栈溢出(StackOverflowError)

栈溢出抛出java.lang.StackOverflowError错误,出现此种情况是因为方法运行的时候栈的深度超过了虚拟机容许的最大深度所致。

出现这种情况,一般情况下是程序错误所致的,比如写了一个死递归,就有可能造成此种情况。 下面我们通过一段代码来模拟一下此种情况的内存溢出。

OOMTest.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 
import java.util.*; import java.lang.*; public class OOMTest{   public void stackOverFlowMethod(){  stackOverFlowMethod();  }   public static void main(String... args){  OOMTest oom = new OOMTest();  oom.stackOverFlowMethod();  }  } 

运行上面的代码,会抛出如下的异常:

1 2 
Exception in thread "main" java.lang.StackOverflowError  at OOMTest.stackOverFlowMethod(OOMTest.java:6) 

堆溢出(OutOfMemoryError:java heap space)

堆内存溢出的时候,虚拟机会抛出java.lang.OutOfMemoryError:java heap space,出现此种情况的时候,我们需要根据内存溢出的时候产生的dump文件来具体分析(需要增加-XX:+HeapDumpOnOutOfMemoryErrorjvm启动参数)。出现此种问题的时候有可能是内存泄露,也有可能是内存溢出了。
如果内存泄露,我们要找出泄露的对象是怎么被GC ROOT引用起来,然后通过引用链来具体分析泄露的原因。
如果出现了内存溢出问题,这往往是程序本生需要的内存大于了我们给虚拟机配置的内存,这种情况下,我们可以采用调大-Xmx来解决这种问题。

下面我们通过如下的代码来演示一下此种情况的溢出:

OOMTest.java
1 2 3 4 5 6 7 8 9 10 
import java.util.*; import java.lang.*; public class OOMTest{   public static void main(String... args){  List<byte[]> buffer = new ArrayList<byte[]>();  buffer.add(new byte[10*1024*1024]);  }  } 

我们通过如下的命令运行上面的代码:

java -verbose:gc -Xmn10M -Xms20M -Xmx20M -XX:+PrintGC OOMTest

程序输入如下的信息:

Terminal output
1 2 3 4 5 
[GC 1180K->366K(19456K), 0.0037311 secs] [Full GC 366K->330K(19456K), 0.0098740 secs] [Full GC 330K->292K(19456K), 0.0090244 secs] Exception in thread "main" java.lang.OutOfMemoryError: Java heap space  at OOMTest.main(OOMTest.java:7) 

从运行结果可以看出,JVM进行了一次Minor gc和两次的Major gc,从Major gc的输出可以看出,gc以后old区使用率为134K,而字节数组为10M,加起来大于了old generation的空间,所以抛出了异常,如果调整-Xms21M,-Xmx21M,那么就不会触发gc操作也不会出现异常了。

通过上面的实验其实也从侧面验证了一个结论:当对象大于新生代剩余内存的时候,将直接放入老年代,当老年代剩余内存还是无法放下的时候,出发垃圾收集,收集后还是不能放下就会抛出内存溢出异常了

持久带溢出(OutOfMemoryError: PermGen space)

我们知道Hotspot jvm通过持久带实现了Java虚拟机规范中的方法区,而运行时的常量池就是保存在方法区中的,因此持久带溢出有可能是运行时常量池溢出,也有可能是方法区中保存的class对象没有被及时回收掉或者class信息占用的内存超过了我们配置。当持久带溢出的时候抛出java.lang.OutOfMemoryError: PermGen space。 
我在工作可能在如下几种场景下出现此问题。

  1. 使用一些应用服务器的热部署的时候,我们就会遇到热部署几次以后发现内存溢出了,这种情况就是因为每次热部署的后,原来的class没有被卸载掉。
  2. 如果应用程序本身比较大,涉及的类库比较多,但是我们分配给持久带的内存(通过-XX:PermSize和-XX:MaxPermSize来设置)比较小的时候也可能出现此种问题。
  3. 一些第三方框架,比如spring,hibernate都通过字节码生成技术(比如CGLib)来实现一些增强的功能,这种情况可能需要更大的方法区来存储动态生成的Class文件。

我们知道Java中字符串常量是放在常量池中的,String.intern()这个方法运行的时候,会检查常量池中是否存和本字符串相等的对象,如果存在直接返回对常量池中对象的引用,不存在的话,先把此字符串加入常量池,然后再返回字符串的引用。那么我们就可以通过String.intern方法来模拟一下运行时常量区的溢出.下面我们通过如下的代码来模拟此种情况:

OOMTest.java
1 2 3 4 5 6 7 8 9 10 11 12 
import java.util.*; import java.lang.*; public class OOMTest{   public static void main(String... args){  List<String> list = new ArrayList<String>();  while(true){  list.add(UUID.randomUUID().toString().intern());  }  }  } 

我们通过如下的命令运行上面代码:

java -verbose:gc -Xmn5M -Xms10M -Xmx10M -XX:MaxPermSize=1M -XX:+PrintGC OOMTest

运行后的输入如下图所示:

1 2 3 
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space  at java.lang.String.intern(Native Method)  at OOMTest.main(OOMTest.java:8) 

通过上面的代码,我们成功模拟了运行时常量池溢出的情况,从输出中的PermGen space可以看出确实是持久带发生了溢出,这也验证了,我们前面说的Hotspot jvm通过持久带来实现方法区的说法。

OutOfMemoryError:unable to create native thread

最后我们在来看看java.lang.OutOfMemoryError:unable to create natvie thread这种错误。 出现这种情况的时候,一般是下面两种情况导致的:

  1. 程序创建的线程数超过了操作系统的限制。对于Linux系统,我们可以通过ulimit -u来查看此限制。
  2. 给虚拟机分配的内存过大,导致创建线程的时候需要的native内存太少。我们都知道操作系统对每个进程的内存是有限制的,我们启动Jvm,相当于启动了一个进程,假如我们一个进程占用了4G的内存,那么通过下面的公式计算出来的剩余内存就是建立线程栈的时候可以用的内存。 线程栈总可用内存=4G-(-Xmx的值)- (-XX:MaxPermSize的值)- 程序计数器占用的内存 通过上面的公式我们可以看出,-Xmx 和 MaxPermSize的值越大,那么留给线程栈可用的空间就越小,在-Xss参数配置的栈容量不变的情况下,可以创建的线程数也就越小。因此如果是因为这种情况导致的unable to create native thread,那么要么我们增大进程所占用的总内存,或者减少-Xmx或者-Xss来达到创建更多线程的目的。

    本文主要内容分为如下几部分:

    1. Java虚拟机规范规定的Jvm 内存概念模型
    2. HotSpot Jvm 内存的模型
    3. 常见的垃圾收集策略
    4. HotSpot Jvm 垃圾收集策略
    5. HotSpot Jvm 垃圾收集器的配置策略
      本篇文章只涉及1,2,3部分,第4和5部分Jvm内存模型以及垃圾收集策略解析系列(二)文章我们再来说。

    1.Java虚拟机内存概念模型

    在本文开始之前,首先我们这里要明确一点,Jvm的内存模型分为Java 虚拟机规范规定的概念模型以及具体厂商的实现模型。不同的厂商的Jvm在实现方式上可能会存在差别,本文中如果没有特别指出,Jvm 默认都指HotSpot Jvm。

    OK,明确了上面一点以后,咋们首先来看一下Java 虚拟机规范对Jvm 内存模型的要求,咋们具体可以参考下图:
    Java Classloader机制解析_第2张图片

    由上图可以看出Jvm 运行时的内存主要分为两部分:

    1. 所有线程共享的区域
      线程共享的区域分为又分为下面两部分:

      1. 方法区
        方法区主要存放虚拟机中已经加载的类的信息,静态变量,常量等。方法区中有一块非常重要的区域运行时常量池,我们知道Java Class的文件结构中有一个叫常量池的结构,它主要存放了编译器生成的各种字面常量和符号引用,这部分的内容也将放到运行时常量池。

      2. 堆中存放了Java 对象,现代虚拟机,对于堆又进行了进一步的划分,具体细节到下面的HotSpot jvm 的实现中再来说说。
    2. 线程独享的区域

      1. Java虚拟机栈
        Java 方法在运行的时候,虚拟机会分配给每次方法调用一个栈帧,栈帧中包含了局部变量表,操作数栈,方法出口等信息。
      2. 本地方法栈
        本地方法栈是为Java 中执行Native 方法服务的,作用和Java 虚拟机栈是一样的。
      3. 程序计数器 
        每个线程都会有自己的程序计数器方面在执行Java 方法的时候,能顺利的找到下面要执行的指令。

    2.HotSpot jvm内存实现模型

    Ok,上面说了Java 虚拟机规范规定的虚拟机概念模型中的内存布局,接下来我们以Sun公司的HopSpot jvm为例(现以及被Oracle 收购),来具体的看看JVM的内存模型和垃圾收集方面的知识。

    Java HotSopt jvm 将JVM的堆内存分为了几个区域,我们可以通过下图来形象的描述:Java Classloader机制解析_第3张图片

    从上图我们可以清晰的看到HotSpot jvm 将堆分为如下三部分:

    1. 新生代(Young)
      新生代被划分为三部分,Eden区和两个大小严格相同的Survivor区,其中Survivor区间,某一时刻只有其中一个是被使用的,另外一个留做垃圾收集时复制对象用,在Young区间变满的时候,minor GC就会将存活的对象移到空闲的Survivor区间中,根据JVM的策略,在经过几次垃圾收集后,仍然存活于Survivor的对象将被移动到Tenured区间。

    2. 老年代(Tenured)
      Tenured区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在Young复制转移一定的次数以后,对象就会被转移到Tenured区,一般如果系统中用了application级别的缓存,缓存中的对象往往会被转移到这一区间。

    3. 持久代(Perm)
      Perm代主要保存class,method,filed对象,这部分的空间一般不会溢出,除非一次性加载了很多的类,不过在涉及到热部署的应用服务器的时候,有时候会遇到java.lang.OutOfMemoryError : PermGen space 的错误,造成这个错误的很大原因就有可能是每次都重新部署,但是重新部署后,类的class没有被卸载掉,这样就造成了大量的class对象保存在了perm中,这种情况下,一般重新启动应用服务器可以解决问题。

      说到持久代,我们有必要来说一下虚拟机概念模型和实现模型方面的差异,在HotSpot虚拟机的实现中利用持久代实现了概念模型的方法区。

    OK,接下来我们再来看看 HotSpot jvm都给我们提供了哪些参数来对内存进行配置:

    • 配置总内存
      -Xms :指定了JVM初始启动以后初始化内存
      -Xmx:指定JVM堆得最大内存,在JVM启动以后,会分配-Xmx参数指定大小的内存给JVM,但是不一定全部使用,JVM会根据-Xms参数来调节真正用于JVM的内存
      -Xmx-Xms之差就是三个Virtual空间的大小
    • 配置新生代 
      -Xmn: 参数设置了年轻代的大小 
      -XX:SurvivorRatio: 表示eden和一个surivivor的比例,缺省值为8.假如-XX:SurvivorRatio=32意味着eden和一个survivor的比值是32:1,这样一个Survivor就占Young区的1/34.
    • 配置老年代 
      -XX:NewRatio: 表示年老年代和新生代内存的比例,缺省值为2.假如-XX:NewRatio=8意味着tenured 和 young的比值8:1
    • 配置持久代
      -XX:MaxPermSize:表示持久代的最大值

    有了上面虚拟机的内存模型的相关介绍做为铺垫,我们接着来看一下有关垃圾收集方面的知识。

    3.常见的垃圾收集策略

    垃圾收集提供了内存管理的机制,使得应用程序不需要在关注内存如何释放,内存用完后,垃圾收集会进行收集,这样就减轻了因为人为的管理内存而造成的错误,比如在C++语言里,出现内存泄露时很常见的。Java语言是目前使用最多的依赖于垃圾收集器的语言,但是垃圾收集器策略从20世纪60年代就已经流行起来了,比如Smalltalk,Eiffel等编程语言也集成了垃圾收集器的机制。

    所有的垃圾收集算法都面临同一个问题,那就是找出应用程序不可到达的内存块,将其释放,这里面得不可到达主要是指应用程序已经没有内存块的引用了,而在JAVA中,某个对象对应用程序是可到达的是指:这个对象被根(根主要是指类的静态变量,常量或者活跃在所有线程栈的对象的引用)引用或者对象被另一个可到达的对象引用,可以通过下图来形象的描述:Java Classloader机制解析_第4张图片

    下面我们介绍一下几种常见的垃圾收集策略:

    1. Reference Counting(引用计数)

    引用计数是最简单直接的一种方式,这种方式在每一个对象中增加一个引用的计数,这个计数代表当前程序有多少个引用引用了此对象,如果此对象的引用计数变为0,那么此对象就可以作为垃圾收集器的目标对象来收集。
    优点:
    简单,直接,不需要暂停整个应用
    缺点:
    需要编译器的配合,编译器要生成特殊的指令来进行引用计数的操作,比如每次将对象赋值给新的引用,或者者对象的引用超出了作用域等。
    不能处理循环引用的问题

    2. 跟踪收集器

    跟踪收集器首先要暂停整个应用程序,然后开始从根对象扫描整个堆,判断扫描的对象是否有对象引用。 如果每次扫描整个堆,那么势必让GC的时间变长,从而影响了应用本身的执行。因此在JVM里面采用了分代收集,在新生代收集的时候minor gc只需要扫描新生代,而不需要扫描老生代。
    JVM采用了分代收集以后,minor gc只扫描新生代,但是minor gc怎么判断是否有老生代的对象引用了新生代的对象,JVM采用了卡片标记的策略,卡片标记将老生代分成了一块一块的,划分以后的每一个块就叫做一个卡片,JVM采用卡表维护了每一个块的状态,当JAVA程序运行的时候,如果发现老生代对象引用或者释放了新生代对象的引用,那么就JVM就将卡表的状态设置为脏状态,这样每次minor gc的时候就会只扫描被标记为脏状态的卡片,而不需要扫描整个堆。具体如下图:
    Java Classloader机制解析_第5张图片上面说了Jvm 需要判断对象是否有引用存在,而Java 中的引用又分为了如下几种,不同种类的引用对垃圾收集有不同的影响,下面我们分开描述一下:
    1.Strong Reference(强引用)
    强引用是JAVA中默认采用的一种方式,我们平时创建的引用都属于强引用。如果一个对象没有强引用,那么对象就会被回收。
    2.Soft Reference(软引用) 软引用的对象在GC的时候不会被回收,只有当内存不够用的时候才会真正的回收,因此软引用适合缓存的场合,这样使得缓存中的对象可以尽量的再内存中待长久一点。 
    3.Weak Reference(弱引用)
    弱引用有利于对象更快的被回收,假如一个对象没有强引用只有弱引用,那么在GC后,这个对象肯定会被回收。
    4.Phantom reference(幽灵引用) 
    幽灵引用说是引用,但是你不能通过幽灵引用来获取对象实例,它主要目的是为了当设置了幽灵引用的对象在被回收的时候可以收到通知。 
    跟踪收集器常见的有如下几种:

    2.1 Mark-Sweep Collector(标记-清除收集器)

    标记清除收集器最早由Lisp的发明人于1960年提出,标记清除收集器停止所有的工作,从根扫描每个活跃的对象,然后标记扫描过的对象,标记完成以后,清除那些没有被标记的对象。
    优点:
    解决循环引用的问题
    不需要编译器的配合,从而就不执行额外的指令
    缺点: 
    每个活跃的对象都要进行扫描,收集暂停的时间比较长。

    2.2 Copying Collector(复制收集器)

    复制收集器将内存分为两块一样大小空间,某一个时刻,只有一个空间处于活跃的状态,当活跃的空间满的时候,GC就会将活跃的对象复制到未使用的空间中去,原来不活跃的空间就变为了活跃的空间。复制收集器具体过程可以参考下图:Java Classloader机制解析_第6张图片优点: 
    只扫描可以到达的对象,不需要扫描所有的对象,从而减少了应用暂停的时间
    缺点:
    需要额外的空间消耗,某一个时刻,总是有一块内存处于未使用状态
    复制对象需要一定的开销

    2.3 Mark-Compact Collector(标记-整理收集器)

    标记整理收集器汲取了标记清除和复制收集器的优点,它分两个阶段执行,在第一个阶段,首先扫描所有活跃的对象,并标记所有活跃的对象,第二个阶段首先清除未标记的对象,然后将活跃的的对象复制到堆得底部。标记整理收集器的过程示意图请参考下图:Java Classloader机制解析_第7张图片Mark-compact策略极大的减少了内存碎片,并且不需要像Copy Collector一样需要两倍的空间。

    本篇文章介绍了Jvm内存的概念模型,HotSpot jvm内存实现模型,以及常见的垃圾收集策略,本系列的下篇文章将介绍,HotSpot jvm中的内存模型以及HotSpot Jvm 垃圾收集器的配置策略。下篇请参加如下文章:
    Jvm内存模型以及垃圾收集策略解析系列(二)

    最后附上一些JVM 垃圾收集方面的文章供大家参考:
    Java theory and practice: A brief history of garbage collection 
    Java theory and practice: Garbage collection in the HotSpot JVM
    Understanding CMS GC Logs 
    Java HotSpot VM Options

  1. Java虚拟机规范规定的Jvm 内存概念模型
  2. HotSpot Jvm 内存的模型
  3. 常见的垃圾收集策略
  4. HotSpot Jvm 垃圾收集策略
  5. HotSpot Jvm 垃圾收集器的配置策略

4.HotSpot Jvm 垃圾收集策略

GC的执行时要耗费一定的CPU资源和时间的,因此在JDK1.2以后,JVM引入了分代收集的策略,其中对新生代采用”Mark-Compact”策略,而对老生代采用了“Mark-Sweep”的策略。其中新生代的垃圾收集器命名为“minor gc”,老生代的GC命名为”Full Gc 或者Major GC”.其中用System.gc()强制执行的是Full Gc. HotSpot Jvm的垃圾收集器按照并发性可以分为如下三种类型:

4.1 串行收集器(Serial Collector)

Serial Collector是指任何时刻都只有一个线程进行垃圾收集,这种策略有一个名字“stop the whole world”,它需要停止整个应用的执行。这种类型的收集器适合于单CPU的机器。 Serial Collector 有如下两个:

  1. Serial Copying Collector:
    此种GC用-XX:UseSerialGC选项配置,它只用于新生代对象的收集。1.5.0以后.
    -XX:MaxTenuringThreshold来设置对象复制的次数。当eden空间不够的时候,GC会将eden的活跃对象和一个名叫From survivor空间中尚不够资格放入Old代的对象复制到另外一个名字叫To Survivor的空间。而此参数就是用来说明到底From survivor中的哪些对象不够资格,假如这个参数设置为31,那么也就是说只有对象复制31次以后才算是有资格的对象。

    这里需要注意几个个问题:
    From Survivor和To survivor的角色是不断的变化的,同一时间只有一块空间处于使用状态,这个空间就叫做From Survivor区,当复制一次后角色就发生了变化。
    如果复制的过程中发现To survivor空间已经满了,那么就直接复制到old generation.
    比较大的对象也会直接复制到Old generation,在开发中,我们应该尽量避免这种情况的发生。

  2. Serial Mark-Compact Collector: 
    串行的标记-整理收集器是JDK5 update6之前默认的老生代的垃圾收集器,此收集使得内存碎片最少化,但是它需要暂停的时间比较长

4.2 并行收集器(Parallel Collector)

Parallel Collector主要是为了应对多CPU,大数据量的环境。
Parallel Collector又可以分为以下三种:

  1. Parallel Copying Collector
    此种GC用-XX:UseParNewGC参数配置,它主要用于新生代的收集,此GC可以配合CMS一起使用,适用于1.4.1以后。
  2. Parallel Mark-Compact Collector
    此种GC用-XX:UseParallelOldGC参数配置,此GC主要用于老生代对象的收集。适用于1.6.0以后。
  3. Parallel scavenging Collector
    此种GC用-XX:UseParallelGC参数配置,它是对新生代对象的垃圾收集器,但是它不能和CMS配合使用,它适合于比较大新生代的情况,此收集器起始于jdk 1.4.0。它比较适合于对吞吐量高于暂停时间的场合。

串行收集器和并行收集器可以通过如下的图来表示:

Java Classloader机制解析_第8张图片

4.3 并发收集器 (Concurrent Collector)

Concurrent Collector通过并行的方式进行垃圾收集,这样就减少了垃圾收集器收集一次的时间,在HotSpot Jvm中,我们称之为CMS GC,这种GC在实时性要求高于吞吐量的时候比较有用。此种GC可以用参数-XX:UseConcMarkSweepGC配置,此GC主要用于老生代和Perm代的收集。 并发收集器可以通过下图形象的描述:Java Classloader机制解析_第9张图片

CMS GC有可能出现并发模型失败:

并发模型失败:我们CMS GC在运行的时候,用户线程也在运行,当gc的速度比新增对象的速度慢的时候,或者说当正在GC的时候,老年代的空间不能满足用户线程内存分配的需求的时候,就会出现并发模型失败,出现并发模型失败的时候,JVM会触发一次stop-the-world的Full GC这将导致暂停时间过长。不过CMS GC提供了一个参数-XX:CMSInitiatingOccupancyFraction来指定当老年代的空间超过某个值的时候即触发GC。因此如果此参数设置的过高,可能会导致更多的并发模型失败。

并发和并行收集器区别:

对于并发和并行收集器,我们需要注意一点:并发收集器是指垃圾收集器线程和应用线程可以并发的执行,也就是清除的时候不需要stop the world,但是并行收集器指的的是可以多个线程并行的进行垃圾收集,并行收集器还是要暂停应用的(即所谓的stop the world)

5.HotSpot Jvm 垃圾收集器的配置策略

通过上面的描述,我们知道HotSpot Jvm中都有哪些垃圾收集器供我们使用,接下来我们总结一下如何配置垃圾收集器。在继续之前我们需要明白,上面所讲的垃圾收集器有些用于新生代,有些用于老年代,并且不是任何两个都可以配对使用的,下面我们通过下图来形象的描述一下哪些收集器可以配对使用:Java Classloader机制解析_第10张图片

Ok,知道新生代和老年代垃圾收集器都有哪些收集器以后,咋们接下来看看具体如何来选择垃圾收集器。这需要根据我们的应用特点来进行选择。下面我们分两种情况来分别描述一下不同情况下的垃圾收集配置策略。

5.1 吞吐量优先

吞吐量是指GC的时间与运行总时间的比值,比如系统运行了100分钟,而GC占用了一分钟,那么吞吐量就是99%,吞吐量优先一般运用于对响应性要求不高的场合,比如web应用,因为网络传输本来就有延迟的问题,GC造成的短暂的暂停使得用户以为是网络阻塞所致。
吞吐量优先可以通过-XX:GCTimeRatio来指定。当通过-XX:GCTimeRatio不能满足系统的要求以后,我们可以更加细致的来对JVM进行调优。
首先因为要求高吞吐量,这样就需要一个较大的Young generation,此时就需要引入“Parallel scavenging Collector”,可以通过参数:-XX:UseParallelGC来配置。

Jvm config
1 
java -server -Xms3072m -Xmx3072m -XX:NewSize=2560m -XX:MaxNewSize=2560 XX:SurvivorRatio=2 - XX:+UseParallelGC 

当年轻代使用了”Parallel scavenge collector“后,老生代就不能使用”CMS”GC了,在JDK1.6之前,此时老生代只能采用串行收集,而JDK1.6引入了并行版本的老生代收集器,可以用参数-XX:UseParallelOldGC来配置。

1.控制并行的线程数 
缺省情况下,Parallel scavenging Collector 会开启与cpu数量相同的线程进行并行的收集,但是也可以调节并行的线程数。假如你想用4个并行的线程去收集Young generation的话,那么就可以配置-XX:ParallelGCThreads=4,此时JVM的配置参数如下:

Jvm config
1 
java -server -Xms3072m -Xmx3072m -XX:NewSize=2560m -XX:MaxNewSize=2560 XX:SurvivorRatio=2 -XX:+UseParallelGC -XX:ParallelGCThreads=4 

2.自动调节新生代 
在采用了”Parallel scavenge collector”后,此GC会根据运行时的情况自动调节survivor ratio来使得性能最优,因此”Parallel scavenge collector”应该总是开启此参数。此时JVM的参数配置如下:

Jvm config
1 
java -server -Xms3072m -Xmx3072m -XX:+UseParallelGC -XX:ParallelGCThreads=4 -XX:+UseAdaptiveSizePolicy 

5.2 响应时间优先

响应时间优先是指GC每次运行的时间不能太久,这种情况一般使用与对及时性要求很高的系统,比如股票系统等。

响应时间优先可以通过参数-XX:MaxGCPauseMillis来配置,配置以后JVM将会自动调节年轻代,老生代的内存分配来满足参数设置。

在一般情况下,JVM的默认配置就可以满足要求,只有默认配置不能满足系统的要求时候,才会根据具体的情况来对JVM进行性能调优。如果采用默认的配置不能满足系统的要求,那么此时就可以自己动手来调节。此时”Young generation”可以采用”Parallel copying collector”,而”Old generation”则可以采用”Concurrent Collector”.
举个例子来说,以下参数设置了新生代用Parallel Copying Collector,老生代采用CMS收集器。

1 
java -server -Xms512m -Xmx512m -XX:NewSize=64m -XX:MaxNewSize=64m -XX:SurvivorRatio=2 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC 

此时需要注意两个问题:
1.如果没有指定-XX:+UseParNewGC,则采用默认的非并行版本的copy collector.
2.如果在一个单CPU的系统上设置了-XX:+UseParNewGC ,则默认还是采用缺省的copy collector.

1.控制并行的线程数
默认情况下,Parallel copy collector启动和CPU数量一样的线程,也可以通过参数-XX:ParallelGCThreads来指定,比如你想用3个线程去进行并发的复制收集,那么可以改变上述参数如下:

1 
java -server -Xms512m -Xmx512m -XX:NewSize=64m -XX:MaxNewSize=64m -XX:SurvivorRatio=2 -XX:ParallelGCThreads=4 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC 

2.控制并发收集的临界值 
默认情况下,CMS gc在”old generation”空间占用率高于68%的时候,就会进行垃圾收集,而如果想控制收集的临界值,可以通过参数:-XX:CMSInitiatingOccupancyFraction来控制,比如改变上述的JVM配置如下:

1 
java -server -Xms512m -Xmx512m -XX:NewSize=64m -XX:MaxNewSize=64m -XX:SurvivorRatio=2 -XX:ParallelGCThreads=4 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:CMSInitiatingOccupancyFraction=35 

此外顺便说一个参数:-XX:+PrintCommandLineFlags 通过此参数可以知道在没有显示指定内存配置和垃圾收集算法的情况下,JVM采用的默认配置。
比如我在自己的机器上面通过如下命令java -XX:+PrintCommandLineFlags -version得到的结果如下所示:

1 2 3 4 
-XX:InitialHeapSize=524747648 -XX:MaxHeapSize=8395962368 -XX:ParallelGCThreads=23 -XX:+PrintCommandLineFlags -XX:+UseCompressedOops -XX:+UseParallelGC java version "1.6.0_25" Java(TM) SE Runtime Environment (build 1.6.0_25-b06) Java HotSpot(TM) 64-Bit Server VM (build 20.0-b11, mixed mode) 

从输出可以清楚的看到JVM通过自己检测硬件配置而给出的缺省配置。

最后附上一些JVM 垃圾收集方面的文章供大家参考:
Java theory and practice: A brief history of garbage collection 
Java theory and practice: Garbage collection in the HotSpot JVM
Understanding CMS GC Logs 
Java HotSpot VM Options Server-Class Machine Detection


你可能感兴趣的:(Java Classloader机制解析)