JVM详解

  1. 云栖社区>
  2. 博客>
  3. 正文

JVM详解

互联网编程 2017-12-11 13:54:00 浏览465
展开阅读全文

jvm:Java Virtual Machine(Java虚拟机的缩写)
是java实现跨平台的核心支持,链接硬件和操作系统,使一次编译处处运行成为可能。

原理

java编译


img_c048928649d7961ba57618aa9f108c2d.png

img_582beaafe53d4e8e5ae22848eac9ca1b.png
img_912f0a2d0131972cfee278516016a34b.png

I.加载.class文件
II.管理并分配内存
III.执行垃圾收集

JVM是Java程序运行的容器,但是他同时也是操作系统的一个进程,因此他也有他自己的运行的生命周期,也有自己的代码和数据空间。

JVM在整个jdk中处于最底层,负责与操作系统的交互,用来屏蔽操作系统环境,提供一个完整的Java运行环境,因此也叫虚拟计算机.操作系统装入JVM是通过jdk中Java.exe来完成,通过下面4步来完成JVM环境。

1.创建JVM装载环境和配置

2.装载JVM.dll

3.初始化JVM.dll并挂接到JNIENV(JNI调用接口)实例

4.调用JNIEnv实例装载并处理[class类]
相信可以看下 百度百科 https://baike.baidu.com/item/JVM/2902369?fr=aladdin

深入分析ClassLoader

http://blog.csdn.net/tonytfjing/article/details/47212291

why?

ClassLoader,即java类加载器,主要作用是将class加载到JVM内,同时它还要考虑class由谁来加载。在说java的类加载机制之前,还是像前面的博客一样,先说说为什么要知道java的类加载机制。个人认为主要有以下几个原因:

  • 按需加载。JVM启动时不能确定我要加载哪些东西,或者有些类非常大,我只希望用到它时再加载,并非一次性加载所有的class,所以这时候了解了加载机制就可以按需加载了。
  • 类隔离。比如web容器中部署多个应用,应用之间互相可能会有冲突,所以希望尽量隔离,这里可能就要分析各个应用加载的资源和加载顺序之间的冲突,针对这些冲突再自己定些规则,让它们能够愉快地玩耍。
  • 资源回收。如果你不了解java是如何加载资源的,又怎么理解java是如何回收资源的?

一般说到java的类加载机制,都要说到“双亲委派模型”(其实个人很不理解为什么叫“双亲”,其实英文叫“parent”)。使用这种机制,可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次。JVM根据

类名+包名+ClassLoader实例ID 来判定两个类是否相同,是否已经加载过(所以这里可以略微扩展下,可以通过创建不同的classloader实例来实现类的热部署)。有个图很形象(来源见参考资料)。

img_3d0e6d70e93432ef0f2321ae5637cdc5.png

注意:如果想形象地看到java的类加载顺序,可以在运行java的时候加个启动参数,即java –verbose

下面结合上图来详细介绍下java的类加载过程。

BootStrapClassLoader。它是最顶层的类加载器,是由C++编写而成, 已经内嵌到JVM中了。在JVM启动时会初始化该ClassLoader,它主要用来读取Java的核心类库JRE/lib/rt.jar中所有的class文件,这个jar文件中包含了java规范定义的所有接口及实现。
ExtensionClassLoader。它是用来读取Java的一些扩展类库,如读取JRE/lib/ext/*.jar中的包等(这里要注意,有些版本的是没有ext这个目录的)。
AppClassLoader。它是用来读取CLASSPATH下指定的所有jar包或目录的类文件,一般情况下这个就是程序中默认的类加载器。
CustomClassLoader。它是用户自定义编写的,它用来读取指定类文件 。基于自定义的ClassLoader可用于加载非Classpath中(如从网络上下载的jar或二进制)的jar及目录、还可以在加载前对class文件优一些动作,如解密、编码等。
很多资料和文章里说,ExtClassLoader的父类加载器是BootStrapClassLoader,其实这里省掉了一句话,容易造成很多新手(比如我)的迷惑。严格来说,ExtClassLoader的父类加载器是null,只不过在默认的ClassLoader 的 loadClass 方法中,当parent为null时,是交给BootStrapClassLoader来处理的,而且ExtClassLoader 没有重写默认的loadClass方法,所以,ExtClassLoader也会调用BootStrapLoader类加载器来加载,这就导致“BootStrapClassLoader具备了ExtClassLoader父类加载器的功能”。看一下下面的代码就很容易理解上面这一大段话了。

/**
 * 查看父类加载器
 */
private static void test1() {
    ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
    System.out.println("系统类装载器:" + appClassLoader);
    ClassLoader extensionClassLoader = appClassLoader.getParent();
    System.out.println("系统类装载器的父类加载器——扩展类加载器:" + extensionClassLoader);
    ClassLoader bootstrapClassLoader = extensionClassLoader.getParent();
    System.out.println("扩展类加载器的父类加载器——引导类加载器:" + bootstrapClassLoader);
}

可以看出ExtensionClassLoaderparentnull

三个重要的方法

查看classloader的源码可以发现三个重要的方法:

  • loadClass。classloader加载类的入口,此方法负责加载指定名字的类,ClassLoader的实现方法为先从已经加载的类中寻找,如没有则继续从父ClassLoader中寻找,如仍然没找到,则从BootstrapClassLoader中寻找,最后再调用findClass方法来寻找,如要改变类的加载顺序,则可覆盖此方法,如加载顺序相同,则可通过覆盖findClass来做特殊的处理,例如解密、固定路径寻找等,当通过整个寻找类的过程仍然未获取到Class对象时,则抛出ClassNotFoundException。如类需要resolve,则调用resolveClass进行链接。
  • findClass。此方法直接抛出ClassNotFoundException,因此需要通过覆盖loadClass或此方法来以自定义的方式加载相应的类。
  • defineClass。此方法负责将二进制的字节码转换为Class对象,这个方法对于自定义加载类而言非常重要,如二进制的字节码的格式不符合JVM Class文件的格式,抛出ClassFormatError;如需要生成的类名和二进制字节码中的不同,则抛出NoClassDefFoundError;如需要加载的class是受保护的、采用不同签名的或类名是以java.开头的,则抛出SecurityException;如需加载的class在此ClassLoader中已加载,则抛出LinkageError。

好,可能有人看了上面会疑惑,为什么上面说自定义classloader是需要重写findClass而不是loadClass或者其他方法。这里建议查看源码。

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

可以看到,JDK已经在loadClass方法中帮我们实现了ClassLoader搜索类的判断方法,当在loadClass方法中搜索不到类时,loadClass方法就会调用findClass方法来搜索类,所以我们只需重写该方法即可。

看完这一大串可能有人还是不理解,ok,那我们现在就动手写一个自己的ClassLoader,尽量包含上面三个方法。

自定义ClassLoader

先定义一个Person接口。

public interface Person {
    public void say();
}

再定一个高富帅类实现这个接口

public class HighRichHandsome implements Person {

    @Override
    public void say() {
        System.out.println("I don't care whether you are rich or not");
    }

}

好的,开胃菜结束,主角来了,MyClassLoader。

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;

public class MyClassLoader extends ClassLoader{
    /* 
     * 覆盖了父类的findClass,实现自定义的classloader
     */
    @Override
    public Class<?> findClass(String name) {
        byte[] bt = loadClassData(name);
        return defineClass(name, bt, 0, bt.length);
    }

    private byte[] loadClassData(String className) {
        InputStream is = getClass().getClassLoader().getResourceAsStream(
                className.replace(".", "/") + ".class");
        ByteArrayOutputStream byteSt = new ByteArrayOutputStream();
        int len = 0;
        try {
            while ((len = is.read()) != -1) {
                byteSt.write(len);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return byteSt.toByteArray();
    }
}

代码很简单,不解释了,最后在测试类LoaderTest里写个测试方法。

/**
 * 父类classloader
 * @throws Exception
 */
private static void test2() throws Exception{
    MyClassLoader loader = new MyClassLoader();
    Class<?> c = loader.loadClass("com.alibaba.classload.HighRichHandsome");
    System.out.println("Loaded by :" + c.getClassLoader());

    Person p = (Person) c.newInstance();
    p.say();

    HighRichHandsome man = (HighRichHandsome) c.newInstance();
    man.say();    
}

main方法中调用这个方法即可。LoaderTest默认构造函数会设置AppClassLoader为parent, 测试时执行test2()方法会发现HighRichHandsome类是委托AppClassLoader加载的,所以AppClassLoader可以访问到,不会出错。

但是我们再想一下,如果我们直接加载,不委托给父类加载,会出现什么情况?

/**
 * 自己的classloader加载
 * @throws Exception
 */
private static void test3() throws Exception{
    MyClassLoader loader = new MyClassLoader();
    Class<?> c = loader.findClass("com.alibaba.classload.HighRichHandsome");
    System.out.println("Loaded by :" + c.getClassLoader());

    Person p = (Person) c.newInstance();
    p.say();

    //注释下面两行则不报错
    HighRichHandsome man = (HighRichHandsome) c.newInstance();
    man.say();    
}

可以看到,悲剧的报错了。根据class loader命名空间规则,每个class loader都有自己唯一的命名空间,每个class loader 只能访问自己命名空间中的类,如果一个类是委托parent加载的,那么加载后,这个类就类似共享的,parent和child都可以访问到这个类,因为parent是不会委托child加载类的,所以child加载的类parent访问不到。简单来说,即子加载器的命名空间包含了parent加载的所有类,反过来则不成立。

本例中LoaderTest类是AppClassLoader加载的,所以其看不见由MyClassLoader加载的HighRichHandsome类,但Person接口是可以访问的,所以赋给Person类型不会出错。

一些小的知识点

1.Class.forName()

相信大家都写过连接数据库的例子,基本上就是加载驱动,建立连接,创建请求,写prepareStatement,关闭连接之类的。在这里,有一段代码:

public DbTest() {
    try {
        Class.forName("com.mysql.jdbc.Driver");// 加载驱动
        conn = DriverManager.getConnection(url, "root", "");// 建立连接
        stm = conn.createStatement(); // 创建请求
    } catch (Exception e) {
        e.printStackTrace();
    }
}

我相信大家一开始的时候肯定都有些疑惑,就是Class.forName(“com.mysql.jdbc.Driver”),为什么加载驱动是Class.forName,而不是ClassLoader的loadClass?为什么这么写就可以加载驱动了呢?

其实Class.forName()是显示加载类,作用是要求JVM查找并加载指定的类,也就是说JVM会执行该类的静态代码段。查看com.mysql.jdbc.Driver源码可以发现里面有个静态代码块,在加载后,类里面的静态代码块就执行(主要目的是注册驱动,把自己注册进去),所以主要目的就是为了触发这个静态方法。
2.Web容器加载机制

其实web容器的加载机制这一点我说不好,因为没看过诸如tomcat之类的源码,但是根据自己使用感觉以及查了相关资料,可以简单介绍一下。一般服务器端都要求类加载器能够反转委派原则,也就是先加载本地的类,如果加载不到,再到parent中加载。这个得细看,我这里只知道这个概念,所以就不说了。JavaEE规范推荐每个模块的类加载器先加载本类加载的内容,如果加载不到才回到parent类加载器中尝试加载。
3.重复加载与回收

一个class可以被不同的class loader重复加载,但同一个class只能被同一个class loader加载一次。见下列代码:

/**
 * 对象只加载一次,返回true
 */
private static void test4() {
    ClassLoader c1 = LoaderTest.class.getClassLoader();
    LoaderTest loadtest = new LoaderTest();
    ClassLoader c2 = loadtest.getClass().getClassLoader();
    System.out.println("c1.equals(c2):"+c1.equals(c2));
}

类的回收。说到最开始的why,其实java的回收机制我前面有篇博客说的比较详细,这里就插一句废话吧,当某个classloader加载的所有类实例化的所有对象都被回收了,则该classloader会被回收。

垃圾回收GC

http://blog.csdn.net/winniepu/article/details/4829087
http://blog.csdn.net/ITer_ZC/article/details/41746265
http://blog.csdn.net/suifeng3051/article/details/48292193

介绍and调优 http://blog.csdn.net/suifeng3051/article/details/48292193

一、概述

Java GC(Garbage Collection,垃圾收集,垃圾回收)机制,是Java与C++/C的主要区别之一,作为Java开发者,一般不需要专门编写内存回收和垃圾清理代码,对内存泄露和溢出的问题,也不需要像C程序员那样战战兢兢。经过这么长时间的发展,Java GC机制已经日臻完善,几乎可以自动的为我们做绝大多数的事情。

虽然java不需要开发人员显示的分配和回收内存,这对开发人员确实降低了不少编程难度,但也可能带来一些副作用:

1\. 有可能不知不觉浪费了很多内存
2\. JVM花费过多时间来进行内存回收
3\. 内存泄露

因此,作为一名java编程人员,必须学会JVM内存管理和回收机制,这可以帮助我们在日常工作中排查各种内存溢出或泄露问题,解决性能瓶颈,达到更高的并发量,写出更高效的程序。

二、JVM内存空间管理

根据JVM规范,JVM把内存划分了如下几个区域:

1\. 方法区
2\. 堆区
3\. 本地方法栈
4\. 虚拟机栈
5\. 程序计数器 

其中,方法区和堆是所有线程共享的。

img_75e71bc9327c01f9756ea05a35114178.png
这里写图片描述

2.1 方法区

方法区存放了要加载的类的信息(如类名,修饰符)、类中的静态变量、final定义的常量、类中的field、方法信息,当开发人员调用类对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区。方法区是全局共享的,在一定条件下它也会被GC。当方法区使用的内存超过它允许的大小时,就会抛出OutOfMemory:PermGen Space异常。

在Hotspot虚拟机中,这块区域对应的是Permanent Generation(持久代),一般的,方法区上执行的垃圾收集是很少的,因此方法区又被称为持久代的原因之一,但这也不代表着在方法区上完全没有垃圾收集,其上的垃圾收集主要是针对常量池的内存回收和对已加载类的卸载。在方法区上进行垃圾收集,条件苛刻而且相当困难,关于其回后面再介绍。

运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存储编译期就生成的字面常量、符号引用、翻译出来的直接引用(符号引用就是编码是用字符串表示某个变量、接口的位置,直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译);运行时常量池除了存储编译期常量外,也可以存储在运行时间产生的常量,比如String类的intern()方法,作用是String维护了一个常量池,如果调用的字符“abc”已经在常量池中,则返回池中的字符串地址,否则,新建一个常量加入池中,并返回地址。

JVM方法区的相关参数,最小值:--XX:PermSize;最大值

--XX:MaxPermSize

2.2 堆区

堆区是理解JavaGC机制最重要的区域。在JVM所管理的内存中,堆区是最大的一块,堆区也是JavaGC机制所管理的主要内存区域,堆区由所有线程共享,在虚拟机启动时创建。堆区用来存储对象实例及数组值,可以认为java中所有通过new创建的对象都在此分配。

对于堆区大小,可以通过参数-Xms-Xmx来控制,-Xms为JVM启动时申请的最新heap内存,默认为物理内存的1/64但小于1GB;-Xmx为JVM可申请的最大Heap内存,默认为物理内存的1/4但小于1GB,默认当剩余堆空间小于40%时,JVM会增大Heap到-Xmx大小,可通过-XX:MinHeapFreeRadio参数来控制这个比例;当空余堆内存大于70%时,JVM会减小Heap大小到-Xms指定大小,可通过-XX:MaxHeapFreeRatio来指定这个比例。对于系统而言,为了避免在运行期间频繁的调整Heap大小,我们通常将-Xms和-Xmx设置成一样。

为了让内存回收更加高效(后面会具体讲为何要分代划分),从Sun JDK 1.2开始对堆采用了分代管理方式,如下图所示:

img_35a93353472d9b7e154b6d464716587b.png
这里写图片描述

年轻代(Young Generation)

对象在被创建时,内存首先是在年轻代进行分配(注意,大对象可以直接在老年代分配)。当年轻代需要回收时会触发Minor GC(也称作Young GC)。

年轻代由Eden Space和两块相同大小的Survivor Space(又称S0和S1)构成,可通过-Xmn参数来调整新生代大小,也可通过-XX:SurvivorRadio来调整Eden Space和Survivor Space大小。不同的GC方式会按不同的方式来按此值划分Eden Space和Survivor Space,有些GC方式还会根据运行状况来动态调整Eden、S0、S1的大小。

年轻代的Eden区内存是连续的,所以其分配会非常快;同样Eden区的回收也非常快(因为大部分情况下Eden区对象存活时间非常短,而Eden区采用的复制回收算法,此算法在存活对象比例很少的情况下非常高效,后面会详细介绍)。

如果在执行垃圾回收之后,仍没有足够的内存分配,也不能再扩展,将会抛出OutOfMemoryError:Java Heap Space异常。

老年代(Old Generation)

老年代用于存放在年轻代中经多次垃圾回收仍然存活的对象,可以理解为比较老一点的对象,例如缓存对象;新建的对象也有可能在老年代上直接分配内存,这主要有两种情况:一种为大对象,可以通过启动参数设置-XX:PretenureSizeThreshold=1024,表示超过多大时就不在年轻代分配,而是直接在老年代分配。此参数在年轻代采用Parallel Scavenge GC时无效,因为其会根据运行情况自己决定什么对象直接在老年代上分配内存;另一种为大的数组对象,且数组对象中无引用外部对象。

当老年代满了的时候就需要对老年代进行垃圾回收,老年代的垃圾回收称作Major GC(也称作Full GC)。

老年代所占用的内存大小为-Xmx对应的值减去-Xmn对应的值。

2.3 本地方法栈(Native Method Stack)

本地方法栈用于支持native方法的执行,存储了每个native方法调用的状态。本地方法栈和虚拟机方法栈运行机制一致,它们唯一的区别就是,虚拟机栈是执行Java方法的,而本地方法栈是用来执行native方法的,在很多虚拟机中(如Sun的JDK默认的HotSpot虚拟机),会将本地方法栈与虚拟机栈放在一起使用。

2.4 程序计数器(Program Counter Register)

程序计数器是一个比较小的内存区域,可能是CPU寄存器或者操作系统内存,其主要用于指示当前线程所执行的字节码执行到了第几行,可以理解为是当前线程的行号指示器。字节码解释器在工作时,会通过改变这个计数器的值来取下一条语句指令。 每个程序计数器只用来记录一个线程的行号,所以它是线程私有(一个线程就有一个程序计数器)的。

如果程序执行的是一个Java方法,则计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是一个本地(native,由C语言编写完成)方法,则计数器的值为Undefined,由于程序计数器只是记录当前指令地址,所以不存在内存溢出的情况,因此,程序计数器也是所有JVM内存区域中唯一一个没有定义OutOfMemoryError的区域。
2.5 虚拟机栈(JVM Stack)

虚拟机栈占用的是操作系统内存,每个线程都对应着一个虚拟机栈,它是线程私有的,而且分配非常高效。一个线程的每个方法在执行的同时,都会创建一个栈帧(Statck Frame),栈帧中存储的有局部变量表、操作站、动态链接、方法出口等,当方法被调用时,栈帧在JVM栈中入栈,当方法执行完成时,栈帧出栈。

局部变量表中存储着方法的相关局部变量,包括各种基本数据类型,对象的引用,返回地址等。在局部变量表中,只有long和double类型会占用2个局部变量空间(Slot,对于32位机器,一个Slot就是32个bit),其它都是1个Slot。需要注意的是,局部变量表是在编译时就已经确定好的,方法运行所需要分配的空间在栈帧中是完全确定的,在方法的生命周期内都不会改变。

虚拟机栈中定义了两种异常,如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出StatckOverFlowError(栈溢出);不过多数Java虚拟机都允许动态扩展虚拟机栈的大小(有少部分是固定长度的),所以线程可以一直申请栈,直到内存不足,此时,会抛出OutOfMemoryError(内存溢出)
2.6 Java对象访问方式

一般来说,一个Java的引用访问涉及到3个内存区域:JVM栈,堆,方法区。以最简单的本地变量引用:Object objRef = new Object()为例:

Object objRef 表示一个本地引用,存储在JVM栈的本地变量表中,表示一个reference类型数据;
new Object()作为实例对象数据存储在堆中;
堆中还记录了能够查询到此Object对象的类型数据(接口、方法、field、对象类型等)的地址,实际的数据则存储在方法区中;
在Java虚拟机规范中,只规定了指向对象的引用,对于通过reference类型引用访问具体对象的方式并未做规定,不过目前主流的实现方式主要有两种:

2.6.1 通过句柄访问

通过句柄访问的实现方式中,JVM堆中会划分单独一块内存区域作为句柄池,句柄池中存储了对象实例数据(在堆中)和对象类型数据(在方法区中)的指针。这种实现方法由于用句柄表示地址,因此十分稳定。

img_599170c638e6d41e22a2ba2cfd412c3d.png

2.6.2 通过直接指针访问

通过直接指针访问的方式中,reference中存储的就是对象在堆中的实际地址,在堆中存储的对象信息中包含了在方法区中的相应类型数据。这种方法最大的优势是速度快,在HotSpot虚拟机中用的就是这种方式。

img_a5898f2776d2653ad6acff6fdcb5a5fd.png

三、JVM内存分配

Java对象所占用的内存主要在堆上实现,因为堆是线程共享的,因此在堆上分配内存时需要进行加锁,这就导致了创建对象的开销比较大。当堆上空间不足时,会出发GC,如果GC后空间仍然不足,则会抛出OutOfMemory异常。

为了提升内存分配效率,在年轻代的Eden区HotSpot虚拟机使用了两种技术来加快内存分配 ,分别是bump-the-pointerTLAB(Thread-Local Allocation Buffers)。由于Eden区是连续的,因此bump-the-pointer技术的核心就是跟踪最后创建的一个对象,在对象创建时,只需要检查最后一个对象后面是否有足够的内存即可,从而大大加快内存分配速度;而对于TLAB技术是对于多线程而言的, 它会为每个新创建的线程在新生代的Eden Space上分配一块独立的空间,这块空间称为TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行情况计算而得。可通过-XX:TLABWasteTargetPercent来设置其可占用的Eden Space的百分比,默认是1%。在TLAB上分配内存不需要加锁,一般JVM会优先在TLAB上分配内存,如果对象过大或者TLAB空间已经用完,则仍然在堆上进行分配。因此,在编写程序时,多个小对象比大的对象分配起来效率更高。可在启动参数上增加-XX:+PrintTLAB来查看TLAB空间的使用情况。

img_382ed50769d1ee921c9fd70462ac22c6.png
这里写图片描述

对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次Minor GC后存活了下来),则会被复制到年老代,年老代的空间一般比年轻代大,能存放更多的对象,在年老代上发生的GC次数也比年轻代少。当年老代内存不足时,将执行Major GC,也叫 Full GC。

可以使用-XX:+UseAdaptiveSizePolicy开关来控制是否采用动态控制策略,如果动态控制,则动态调整Java堆中各个区域的大小以及进入老年代的年龄。

如果对象比较大(比如长字符串或大数组),年轻代空间不足,则大对象会直接分配到老年代上(大对象可能触发提前GC,应少用,更应避免使用短命的大对象)。用

-XX:PretenureSizeThreshold来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。

四、内存的回收方式

JVM通过GC来回收堆和方法区中的内存,这个过程是自动执行的。说到Java GC机制,其主要完成3件事:确定哪些内存需要回收;确定什么时候需要执行GC;如何执行GC。JVM主要采用收集器的方式实现GC,主要的收集器有引用计数收集器和跟踪收集器。

4.1 引用计数收集器

引用计数器采用分散式管理方式,通过计数器记录对象是否被引用。当计数器为0时,说明此对象已经不再被使用,可进行回收,如图所示:

img_2f1a8bc07c86129ecb3e0cd0dacfe19e.png
这里写图片描述

在上图中,ObjectA释放了对ObjectB的引用后,ObjectB的引用计数器变为0,此时可回收ObjectB所占有的内存。

引用计数器需要在每次对象赋值时进行引用计数器的增减,他有一定消耗。另外,引用计数器对于循环引用的场景没有办法实现回收。例如在上面的例子中,如果ObjectB和ObjectC互相引用,那么即使ObjectA释放了对ObjectB和ObjectC的引用,也无法回收ObjectB、ObjectC,因此对于java这种会形成复杂引用关系的语言而言,引用计数器是非常不适合的,SunJDK在实现GC时也未采用这种方式。

4.2 跟踪收集器

跟踪收集器采用的为集中式的管理方式,会全局记录数据引用的状态。基于一定条件的触发(例如定时、空间不足时),执行时需要从根集合来扫描对象的引用关系,这可能会造成应用程序暂停。主要有复制(Copying)标记-清除(Mark-Sweep)标记-压缩(Mark-Compact)三种实现算法。

复制(Copying)

复制采用的方式为从根集合扫描出存活的对象,并将找到的存活的对象复制到一块新的完全未被使用的空间中,如图所示:

img_bea935f41d7bbfec5e90e0940228c2f5.png
这里写图片描述

复制收集器方式仅需要从根集合扫描所有存活对象,当要回收的空间中存活对象较少时,复制算法会比较高效(年轻代的Eden区就是采用这个算法),其带来的成本是要增加一块空的内存空间及进行对象的移动。

标记-清除(Marking-Deleting)

标记-清除采用的方式为从根集合开始扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未标记的对象,并进行清除,标记和清除过程如下图所示:

img_d9165d46173de2cf0e0030e92db0153f.png
这里写图片描述

上图中蓝色的部分是有被引用的存活的对象,褐色部分没被引用的可回收的对象。在marking阶段为了mark对象,所有的对象都会被扫描一遍,扫描这个过程是比较耗时的。

img_4fe0ec8ac22bda309af190cebffaa943.png
这里写图片描述

清除阶段回收的是没有被引用的对象,存活的对象被保留。内存分配器会持有空闲空间的引用列表,当有分配请求时会查询空闲空间引用列表进行分配。

标记-清除动作不需要进行对象移动,且仅对其不存活的对象进行处理。在空间中存活对象较多的情况下较为高效,但由于标记-清除直接回收不存活对象占用的内存,因此会造成内存碎片。

标记-压缩(Mark-Compact)

标记-压缩和标记-清除一样,是对活的对象进行标记,但是在清除后的处理不一样,标记-压缩在清除对象占用的内存后,会把所有活的对象向左端空闲空间移动,然后再更新引用其对象的指针,如下图所示:

img_9abb9096df7bdb1113d7e7fea984c1d6.png
这里写图片描述

很明显,标记-压缩在标记-清除的基础上对存活的对象进行了移动规整动作,解决了内存碎片问题,得到更多连续的内存空间以提高分配效率,但由于需要对对象进行移动,因此成本也比较高。

五、虚拟机中的GC过程

5.1 为什么要分代回收?

在一开始的时候,JVM的GC就是采用标记-清除-压缩方式进行的,这么做并不是很高效,因为当对象分配的越来越多时,对象列表也越来也大,扫描和移动越来越耗时,造成了内存回收越来越慢。然而,经过根据对java应用的分析,发现大部分对象的存活时间都非常短,只有少部分数据存活周期是比较长的,请看下面对java对象内存存活时间的统计:

img_2b848fed2729f754deb08bf98c1f8129.png
这里写图片描述

从图表中可以看出,大部分对象存活时间是非常短的,随着时间的推移,被分配的对象越来越少。

5.2 虚拟机中GC的过程

经过上面介绍,我们已经知道了JVM为何要分代回收,下面我们就详细看一下整个回收过程。

  1. 在初始阶段,新创建的对象被分配到Eden区,survivor的两块空间都为空。
img_3a94055af21cb6a5514a66b059dc4dbe.png
  1. 当Eden区满了的时候,minor garbage 被触发

    img_6a1f9f36100578ff6d1d755de9ccbb02.png
    这里写图片描述
  2. 经过扫描与标记,存活的对象被复制到S0,不存活的对象被回收

    img_8a2ddd511618ca002db78bd75a163707.png
    这里写图片描述
  3. 在下一次的Minor GC中,Eden区的情况和上面一致,没有引用的对象被回收,存活的对象被复制到survivor区。然而在survivor区,S0的所有的数据都被复制到S1,需要注意的是,在上次minor GC过程中移动到S0中的两个对象在复制到S1后其年龄要加1。此时Eden区S0区被清空,所有存活的数据都复制到了S1区,并且S1区存在着年龄不一样的对象,过程如下图所示:

    img_56d64efcaf4364f416d980f6a9427f71.png
    这里写图片描述
  4. 再下一次MinorGC则重复这个过程,这一次survivor的两个区对换,存活的对象被复制到S0,存活的对象年龄加1,Eden区和另一个survivor区被清空。

    img_1a3d8162f702563caa9130fd2f16d384.png
    这里写图片描述
  5. 下面演示一下Promotion过程,再经过几次Minor GC之后,当存活对象的年龄达到一个阈值之后(可通过参数配置,默认是8),就会被从年轻代Promotion到老年代。

    img_0618e98bf283328bcd2b7da30212f252.png
    这里写图片描述
  6. 随着MinorGC一次又一次的进行,不断会有新的对象被promote到老年代。

    img_1824afb093db36f247edb18030814a3e.png
    这里写图片描述
  7. 上面基本上覆盖了整个年轻代所有的回收过程。最终,MajorGC将会在老年代发生,老年代的空间将会被清除和压缩。

    img_cacb5c50ee0102c16270502924118e16.png
    这里写图片描述

从上面的过程可以看出,Eden区是连续的空间,且Survivor总有一个为空。经过一次GC和复制,一个Survivor中保存着当前还活着的对象,而Eden区和另一个Survivor区的内容都不再需要了,可以直接清空,到下一次GC时,两个Survivor的角色再互换。因此,这种方式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是著名的“停止-复制(Stop-and-copy)”清理法(将Eden区和一个Survivor中仍然存活的对象拷贝到另一个Survivor中),这不代表着停止复制清理法很高效,其实,它也只在这种情况下(基于大部分对象存活周期很短的事实)高效,如果在老年代采用停止复制,则是非常不合适的。

老年代存储的对象比年轻代多得多,而且不乏大对象,对老年代进行内存清理时,如果使用停止-复制算法,则相当低效。一般,老年代用的算法是标记-压缩算法,即:标记出仍然存活的对象(存在引用的),将所有存活的对象向一端移动,以保证内存的连续。在发生Minor GC时,虚拟机会检查每次晋升进入老年代的大小是否大于老年代的剩余空间大小,如果大于,则直接触发一次Full GC,否则,就查看是否设置了-XX:+HandlePromotionFailure(允许担保失败),如果允许,则只会进行MinorGC,此时可以容忍内存分配失败;如果不允许,则仍然进行Full GC(这代表着如果设置-XX:+Handle PromotionFailure,则触发MinorGC就会同时触发Full GC,哪怕老年代还有很多内存,所以,最好不要这样做)。

关于方法区即永久代的回收,永久代的回收有两种:常量池中的常量,无用的类信息,常量的回收很简单,没有引用了就可以被回收。对于无用的类进行回收,必须保证3点:

1\. 类的所有实例都已经被回收
2\. 加载类的ClassLoader已经被回收
3\. 类对象的Class对象没有被引用(即没有通过反射引用该类的地方)

永久代的回收并不是必须的,可以通过参数来设置是否对类进行回收。

六、垃圾收集器

通过上面的介绍,我们已经了解到了JVM的内存回收过程,而在虚拟机中,GC是由垃圾回收器来具体执行的,所以,在实际应用场景中我们需要根据应用情况选择合适的垃圾收集器,下面我们就介绍一下垃圾收集器。

6.1 串行(Serial)收集器

串行收集器JavaSE5和6中客户端虚拟机所采用的默认配置,它是最简单的收集器,比较适合于只有一个处理器的系统。在串行收集器中,minor和major GC过程都是用一个线程进行垃圾回收。

使用场景

首先,串行GC一般用在对应用暂停要求不是很高和运行在客户端模式的场景,它仅仅利用一个CPU核心来进行垃圾回收。在现在的硬件条件下,串行GC可以管理很多小内存的应用,并且能够保证相对较小的暂停(在Full GC的情况下大约需要几秒的时间)。另一个通常采用串行GC的场景就是一台机器运行多个JVM虚拟机的情况(JVM虚拟机个数大于CPU核心数),在这种场景下,当一个JVM进行垃圾回收时只利用一个处理器,不会对其它JVM造成较大的影响。最后,在一些内存比较小和CPU核心数比较少的硬件设备中也比较适合采用串行收集器。

相关参数命令

1 启用串行收集器:

-XX:+UseSerialGC

2 命令行示例:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseSerialGC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar

6.2 并行收集器

并行收集器采用多线程的方式来进行垃圾回收,采用并行的方式能够带来极大的CPU吞吐量。它在不进行垃圾回收的时候对正在运行的应用程序没有任何影响,在进程GC的时候采用多线程的方式来提高回收速度,因此,并行收集器非常适用于批处理的情形。当然,如果应用对程序暂停要求很高的话,建议采用下面介绍的并发收集器。默认一个N cpu的机器上,并行回收的线程数为N。当然,并行的数量可以通过参数进行控制: -XX:ParallelGCThreads=<desired number>。并行收集器是Server级别机器(CPU大于2且内存大于2G)上采用的默认回收方式,

在单核CPU的机器上,即使配置了并行收集器,实际回收时仍然采用的是默认收集器。如果一台机器上只有两个CPU,采用并行回收器和默认回收器的效果其实差不多,只有当CPU个数大于2个时,年轻代回收的暂停时间才会减少。

应用场景

并行回收器适用于多CPU、对暂停时间要求短的情况下。通常,一些批处理的应用如报告打印、数据库查询可采用并行收集器。

在年轻代用多线程、老年代用单线程

1 启用命令:-XX:+UseParallelGC

2 命令行示例:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseParallelGC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar

年轻代和老年代都用多线程

1 启用命令:-XX:+UseParallelOldGC

当启用 -XX:+UseParallelOldGC 选项时,年轻代和老年代的垃圾收集都会用多线程进行,在压缩阶段也是多线程。因为HotSpot虚拟机在年轻代采用的是停止-复制算法,年轻代没有压缩过程,而老年代采用的是标记-清除-压缩算法,所以仅在老年代有compact过程。

2 命令行示例:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseParallelOldGC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar

6.3 CMS(Concurrent Mark Sweep)收集器

CMS收集器主要用于永久区,它试图用多线程并发的形式来减少垃圾收集过程中的暂停。CMS收集器不会对存活的对象进行复制或移动。

应用场景

CMS收集器主要用在应用程序对暂停时间要求很高的场景,比如桌面UI应用需要及时响应用户操作事件、服务器必须能快速响应客户端请求或者数据库要快速响应查询请求等等。

相关命令参数

1 启用CMS收集器:-XX:+UseConcMarkSweepGC

2 设置线程数:-XX:ParallelCMSThreads=<n>

3 命令行示例:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseConcMarkSweepGC -XX:ParallelCMSThreads=2 -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar

6.4 G1收集器

G1即Garbage First,它是在java 7中出现的新的收集器,它的目标是替换掉现有的CMS收集器。G1具有并行、并发、增量压缩、暂停时间段等特点,在这里先不做详细介绍。

相关命令参数

1 启用G1收集器:-XX:+UseG1GC

2 命令行示例:

java -Xmx12m -Xms3m -XX:+UseG1GC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar

网友评论

登录后评论
0/500
评论
互联网编程
+ 关注