Java内存模型,垃圾回收机制,常用内存命令及工具

684 阅读18分钟

1. Java简介

JAVA 语言是一门非常纯粹的面向对象编程语言, 它吸收了 C++ 语言的各种优点, 又摒弃了 C++ 里难以理解的多继承、指针等概念, 因此 JAVA 语言具有功能强大和简单易用两个特征。

Sun 公司在 1995年年初发布了 JAVA 语言,并在 1996年年初发布了 JDK 1.0,这个版本包括两部分:

  • 运行环境(即 JRE, Java Runtime Environment)
  • 开发环境(即JDK, Java Development Kit)。

JDK包含了JRE,同时还包含了编译java源码的编译器javac,还包含了很多java程序调试和分析的工具:jconsole,jvisualvm等工具软件,还包含了java程序编写所需的文档和demo例子程序。

image

1.1 Java版本时间线

image

1.2 参考资料

作者Daniel-广:Java系列笔记(3) - Java 内存区域和GC机制(www.cnblogs.com/zhguang/p/3…

2. Java内存区域

如图所示,Java虚拟机所管理的内存主要包含以下几个运行时数据区域:

  1. 程序计数器
  2. Java虚拟机栈
  3. 本地方法栈
  4. Java堆
  5. 方法区(非堆)
  6. 运行时常量池
  7. 直接内存

image

2.1 程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间,它可以看做是当前线程执行的字节码的行号指示器。

每个线程都需要一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,为“线程私有”,是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

情景:

  • 当线程正在执行一个java方法,则这个计数器记录的是正在执行的虚拟机字节码指令的地址
  • 正在执行的是Native方法,则这个计数器值为空

2.2 Java虚拟机栈(Java Virtual Machine Stacks)

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。

通常人们把Java内存分为堆内存和栈内存,这种分法比较粗糙,实际的内存区域远比这种复杂,而这里所说的栈就是我们的虚拟机栈。

每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

局部变量表存放基本数据类型、对象应用类型和returnAddress类型。

2.3 本地方法栈(Native Method Stack)

本地方法栈(Native Method Stack)与虚拟机栈的作用非常类似,区别在于虚拟机栈为虚拟机执行Java方法服务,本地方法栈则为Native方法提供服务。由于两者的区别不大,有的虚拟机甚至把两者合二为一。

下描绘了这种情况,就是当一个线程调用一个本地方法时,本地方法又回调虚拟机中的另一个Java方法。这幅图展示了java虚拟机内部线程运行的全景图。

一个线程可能在整个生命周期中都执行Java方法,操作他的Java栈;或者他可能毫无障碍地在Java栈和本地方法栈之间跳转。

image

上图所示,该线程首先调用了两个Java方法,而第二个Java方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。

图中的本地方法栈显示为 一个连续的内存空间。

假设这是一个C语言栈,期间有两个C函数,他们都以包围在虚线中的灰色块表示。

第一个C函数被第二个Java方法当做本地方法调用,而这个C函数又调用了第二个C函数。之后第二个C函数被第二个Java方法当做本地方法调用,而这个C函数又调用了第二个C函数。之后第二个C函数又通过 本地方法接口回调了一个Java方法(第三个Java方法)。最终这个Java方法又调用了一个Java方法(他成为图中的当前方法)。

2.4 Java堆(Java Heap)

Java堆是Java虚拟机所管理的内存中最大的一块,此内存用于存放对象实例,几乎所有的对象实例都会在这里分配内存。

Java堆是垃圾收集器管理的主要区域,因此很多时候也会被称作“GC堆”,可分为新生代、老年代,再细可分为Eden区、Form Survivor区、To Survivor区等。

可通过控制-Xmx和Xms参数来控制大小。

2.5 方法区(Method Area)

方法区与Java堆一样, 是各个线程共享的内存区域,方法区用于存放Class的相关信息,如:类名,访问修饰符,常量池,字符描述,方法描述等。

这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,为了与堆内存区分开来,也被称为非堆。

2.6 运行时常量池(Runtime Constant Pool)

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

2.7 直接内存(Direct Memory)

并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也会被频繁的使用。

情景:

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

3. 程序举例

3.1 Java堆内存溢出

Java堆用于存储对象实例,只要不断地创建对象就可以使它报出OutOfMemory异常。

public class HeapOOM {

    static class OOMObject{}

    public static void main(String[] args) {
        List<OOMObject> list = new LinkedList<OOMObject>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

当出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟着进一步提示“java heap space”。

image

3.2 栈内存溢出

什么时候会让 Java Method Stack 栈溢出啊?栈的基本特点就是 FILO(First In Last Out),如果 in 的太多而 out 的太少,就好 overflow 了。而 Java Method Stack 的功能就是保存每一次函数调用时的“现场”,即为入栈,函数返回就对应着出栈,所以函数调用的深度越大,栈就变得越大,足够大的时候就会溢出。所以模拟 Java Method Stack 溢出,只要不断递归调用某一函数就可以。

public class JavaVMStackSOF {

    private int stackLength = 1;
    
    public void stackLeak(){
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try{
	        oom.stackLeak();
        } catch(Throwable e){
	        System.out.println("stack length:"+oom.stackLength);
	         e.printStackTrace();
        }
    }
}

运行结果如下图:

image

3.3 方法区溢出

方法区用于存放Class的相关信息,如:类名,访问修饰符,常量池,字符描述,方法描述等。

对于这个区域的测试,基本思路是运行时产生大量的类去填满方法区,直到溢出。

每个类都设置了一个定时器,目的是让类处于运行状态,否则类new 出来没有作用很快就会被垃圾回收器回收,方法区就不会溢出了。

这个例子由于创建了大量定时器,有时候会因此线程过多抛异常,并不是每次都会方法区溢出,大家可以想想有没有其他更好的方法,一起交流。

package com.sigar.practice.jvm;


import java.util.Timer;
import java.util.TimerTask;

/**
 * 测试方法区溢出
 */
public class JavaMethodAreaOOM {

    public static void main(String[] args) {
        while(true){
            Car car = new Car(1, "VOLVO", 5, 1, 1, "S90");
            car.run();
        }
    }
}

class Car{
    private int type;
    private String company;
    private int engine;
    private int framework;
    private int color;
    private String model;

    private String address = "银河系左下角的悬臂上的闪耀太阳系中离太阳第3近的最美丽的星球地球北半球中华人民共和国浙江省杭州市大江东开发区积极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极极汽车厂";

    private Timer timer;

    public Car(int type, String company, int engine, int framework, int color, String model){
        this.type = type;
        this.company = company;
        this.engine = engine;
        this.framework = framework;
        this.color = color;
        this.model = model;

        this.timer = new Timer();
        //延迟1000ms执行程序
        this.timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("Running..." + this.scheduledExecutionTime());
            }
        }, 0,2000);
    }

    public void run(){

    }

    public int getType() {
        return type;
    }

    public void setType(int type) {
        this.type = type;
    }

    public String getCompany() {
        return company;
    }

    public void setCompany(String company) {
        this.company = company;
    }

    public int getEngine() {
        return engine;
    }

    public void setEngine(int engine) {
        this.engine = engine;
    }

    public int getFramework() {
        return framework;
    }

    public void setFramework(int framework) {
        this.framework = framework;
    }

    public int getColor() {
        return color;
    }

    public void setColor(int color) {
        this.color = color;
    }

    public String getModel() {
        return model;
    }

    public void setModel(String model) {
        this.model = model;
    }
}


运行结果:

image

4. Java的垃圾收集器

Java相对于C++等其他语言,有一个很大的区别就是内存的使用和回收。Java不需要析构函数等内存回收方法,基本完全由垃圾收集器来进行内存回收。

4.1 如何判断对象已死

堆是垃圾回收算法的主要工作地点,其他的区域(例如方法区),总会因为各种原因,导致垃圾回收算法的效率和价值不大。 判断对象已经已经死亡是垃圾回收算法主要解决的问题,也出现了很多不同的思路。

以下是两种最重要的垃圾回收算法。

4.1.1 垃圾回收算法 - 引用计数算法

引用计数算法的实现很简单,判断效率也很高,在大部分情况下是一个很不错的算法。思路如下:

给一个对象添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效时,计数器就减1。当计数器为0时,就代表该对象已经失效,就可以被回收。

该算法有一个很严重的问题,就是它无法解决对象间互相引用的情况,如A中有一个B类型的属性,而B中又有A类型的属性,这就导致这两个对象的引用计数器永远不会为0,从而永远无法被垃圾收集器回收。

4.1.2 垃圾回收算法 - 可达性分析算法

在主流的商用程序语言的主流实现中,都是通过该算法来判断对象是否存活。基本思想如下:

通过一系列被称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径被称为引用链,当一个对象到GC Roots没有任何引用链相连时,该对象是不可用的。

image

4.2 引用

无论是引用计数法还是可达性分析算法,都离不开“引用” 的概念。

在JDK1.2之后,Java对引用的概念进行了补充,将引用分为强引用、软引用、弱引用、虚引用四种,由强到弱以此如下:

  • 强引用:类似于 Object obj = new Object();只要强引用还在,垃圾收集器就不会回收掉该对象;
  • 软引用:用来描述一下还有用但非必要的对象,在系统将要发生内存溢出异常之前,会把这些对象列进回收范围中进行第二次回收,如果此后还是没有足够的内存,才会抛出内存溢出异常;
  • 弱引用:被弱引用关联的对象只能生存到下一次垃圾收集器之前。无论当前内存是否足够,都会将它回收掉;
  • 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间产生影响,也无法通过虚引用来取得一个对象实例。它存在的唯一目的就是这个对象被收集器回收时能收到一个系统通知。

4.3 finalize关键字

当对象没有引用到达时,并不会直接把对象回收,而是让它们暂时处于“缓刑”阶段。

可达性回收算法会进行一次筛选,筛选的条件就是该对象是否有必要调用它的finalize方法,因此finalize方法则是对象拯救自己的唯一机会(只需要在方法中把对象的引用重新连上即可)。

如果回收算法认为已经没有必要执行该方法,则会将该对象回收掉。

4.4 垃圾收集算法

在判断完了对象已经死亡之后,就该把对象内存回收掉了,那又有哪些垃圾收集的算法呢?

4.4.3 垃圾收集算法 - 标记清除算法

它的做法是当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被成为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

下面LZ具体解释一下标记和清除分别都会做些什么。

标记:标记的过程其实就是,遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。

清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。

其实这两个步骤并不是特别复杂,也很容易理解。用通俗的话解释一下标记/清除算法,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。

image

如上图所示,该算法有两个缺陷,一个是标记和清除的效率都不高,二是清除完成之后会产生大量的内存碎片,导致以后的程序在生成较大的对象时,无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作

4.4.3 垃圾收集算法 - 复制算法

为了解决效率问题,复制算法出现了。

image

将内存分为两块,当一块满了之后,把这一块中还存活的对象拷贝到另一块中。这就解决了内存碎片的问题,也提高了效率,但是代价就是牺牲了一半的内存,有点太大了

4.4.4 垃圾收集算法 - 标记整理算法

复制算法中,如果存活的对象很多,那就需要复制很多次,效率也会随之降低,标记-整理算法因此出现了。

image

说白了就是把存活的对象往内存的一段移动,然后清理掉端边界以外的内存。

4.4.5 垃圾收集算法 - 分代收集算法

并不是什么新的算法,而是根据对象的存活周期,将内存分块,使用上述的几种算法进行回收。

前面说到了将堆分成Eden区、From Survivor区和To Survivor区,就是这个思想的体现。

大多数情况下,对象在新生代Eden去中分配。当Eden区没有足够的空间进行分配时,虚拟机会执行一次Minor GC。

5. 堆内存分配和回收机制

Java的内存分配和回收主要发生在堆上,所以我们只说堆内存。

Java内存分配和回收的机制概括的说,就是:分代分配,分代回收。

对象将根据存活的时间被分为:年轻代(Young Generation)、年老代(Old Generation)、永久代(Permanent Generation,也就是方法区)。

如下图(来源于《成为JavaGC专家part I》,www.importnew.com/1993.html):

image

5.1 年轻代(Young Generation)

对象被创建时,内存的分配首先发生在年轻代(大对象可以直接被创建在年老代),大部分的对象在创建后很快就不再使用,因此很快变得不可达,于是被年轻代的GC机制清理掉(IBM的研究表明,98%的对象都是很快消亡的),

这个GC机制被称为Minor GC或叫Young GC。注意,Minor GC并不代表年轻代内存不足,它事实上只表示在Eden区上的GC。

年轻代上的内存分配是这样的,年轻代可以分为3个区域:

  • Eden区(伊甸园,亚当和夏娃偷吃禁果生娃娃的地方,用来表示内存首次分配的区域,再贴切不过)
  • 两个存活区(Survivor 0 、Survivor 1)。

内存分配过程为(来源于《成为JavaGC专家part I》,www.importnew.com/1993.html):

image

  1. 绝大多数刚创建的对象会被分配在Eden区,其中的大多数对象很快就会消亡。Eden区是连续的内存空间,因此在其上分配内存极快;
  2. 最初一次,当Eden区满的时候,执行Minor GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区Survivor0(此时,Survivor1是空白的,两个Survivor总有一个是空白的);
  3. 下次Eden区满了,再执行一次Minor GC,将消亡的对象清理掉,将存活的对象复制到Survivor1中,然后清空Eden区;
  4. 将Survivor0中消亡的对象清理掉,将其中可以晋级的对象晋级到Old区,将存活的对象也复制到Survivor1区,然后清空Survivor0区;
  5. 当两个存活区切换了几次(HotSpot虚拟机默认15次,用-XX:MaxTenuringThreshold控制,大于该值进入老年代,但这只是个最大值,并不代表一定是这个值)之后,仍然存活的对象(其实只有一小部分,比如,我们自己定义的对象),将被复制到老年代。

从上面的过程可以看出,Eden区是连续的空间,且Survivor总有一个为空。

经过一次GC和复制,一个Survivor中保存着当前还活着的对象,而Eden区和另一个Survivor区的内容都不再需要了,可以直接清空,到下一次GC时,两个Survivor的角色再互换。

因此,这种方式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是著名的“停止-复制(Stop-and-copy)”清理法(将Eden区和一个Survivor中仍然存活的对象拷贝到另一个Survivor中),这不代表着停止复制清理法很高效,其实,它也只在这种情况下高效,如果在老年代采用停止复制,则挺悲剧的。

5.2 年老代(Old Generation)

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

可以使用-XX:+UseAdaptiveSizePolicy开关来控制是否采用动态控制策略,如果动态控制,则动态调整Java堆中各个区域的大小以及进入老年代的年龄。   如果对象比较大(比如长字符串或大数组),Young空间不足,则大对象会直接分配到老年代上(大对象可能触发提前GC,应少用,更应避免使用短命的大对象)。

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

可能存在年老代对象引用新生代对象的情况,如果需要执行Young GC,则可能需要查询整个老年代以确定是否可以清理回收,这显然是低效的。解决的方法是,年老代中维护一个512 byte的块——”card table“,所有老年代对象引用新生代对象的记录都记录在这里。Young GC时,只要查这里即可,不用再去查全部老年代,因此性能大大提高。

5.3 方法区(永久代):

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

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

6. 常用内存命令及工具

jps		与UNIX命令类的ps命令类似,可以列出正在运行的虚拟机进程
jstat		监视虚拟机各种运行状态信息
jmap		内存映像工具
jstack		Java堆栈跟踪工具
jconsole	Java监视与管理控制台
…

6.1 常用命令 - jmap

命令格式:jmap [option] vmid

option的可选参数有:

-dump 生成Java堆转储快照。格式为:-dump:[live,] format=b,file=<filename>,其中live子  参数说明是否只dump出存活的对象,如 jmap –dump:format=b,file=C:\heap.dum 3500
-finalizerinfo 显示在F-Queue中等待Finalizer线程执行finalize方法的对象
-heap 显示Java堆详细信息
-histo 显示堆中对象统计信息
-permstat 以ClassLoader为统计口径显示永久带内存状态
-F 当虚拟机进程对-dump选项没有响应时,可使用这个选项强制生成dump快照

可以使用memoryAnalyzer对内存对象进行分析

6.2 常用工具 - jconsole

双击打开jdk/bin目录下的jconsole.exe就可以启动它

image