当前位置:   article > 正文

校招面试重点总结_计算机校招面试官常问的问题内存模型

计算机校招面试官常问的问题内存模型

一.JVM专题

1,JVM运行时数据区(内存模型)

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为五大数据区域,包括程序计数器、虚拟机栈、本地方法栈、方法区和堆。

(1)程序计数器

a.什么是程序计数器

程序计数器是当前线程正在执行的字节码的地址。程序计数器是线程隔离的,每一个线程在工作的时候都有一个独立的计数器。

b.字节码的执行原理

字节码是通过字节码解释器进行解释执行。其执行原理为:字节码解释器读取内存中的字节码,按照顺序读取字节码指令,读取一个指令就将其翻译成固定的操作,根据这些操作进行分支,循环,跳转等动作。

c.程序计数器的作用

从字节码的执行原理来看,单线程的情况下程序计数器是可有可无的。因为即使没有程序计数器的情况下,程序会按照指令顺序执行下去,即使遇到了分支跳转这样的流程也会按照跳转到指定的指令处继续顺序执行下去,是完全能够保证执行顺序的。

但是现实中程序往往是多线程协作完成任务的。JVM的多线程是通过CPU时间片轮转来实现的,某个线程在执行的过程中可能会因为时间片耗尽而挂起。当它再次获取时间片时,需要从挂起的地方继续执行。在JVM中,通过程序计数器来记录程序的字节码执行位置。程序计数器具有线程隔离性,每个线程拥有自己的程序计数器

d.程序计数器的特点

(1)程序计数器具有线程隔离性

(2)程序计数器占用的内存空间非常小,可以忽略不计

(3)程序计数器是java虚拟机规范中唯一一个没有规定任何OutofMemeryError的区域

(4)程序执行的时候,程序计数器是有值的,其记录的是程序正在执行的字节码的地址

(5)执行native本地方法时,程序计数器的值为空。原因是native方法是java通过jni调用本地C/C++库来实现,非java字节码实现,所以无法统计

(2)虚拟机栈

每个线程在创建时都会创建一虚拟机栈(生命周期和线程一致),其内部保存一个个的栈帧(Stack Frame) ,对应着一次次的Java方法调用,是线程私有的。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧。

a.局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。局部变量表的容量以变量槽(Variable Slot)为最小单位,每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存储(这并不意味着每个变量槽是32位)。

其中reference类型表示对一个对象实例的引用,没有明确的长度,该类型的作用:一是从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引,二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。种returnAddress类型目前已经很少见了,不做解释。

long和double数据类型以两个变量槽进行存储。不过,由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。

Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是64位数据类型的变量,则说明会同时使用第N和N+1两个变量槽。对于两个相邻的共同存放一个64位数据的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个。

当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,这样的设计除了节省栈帧空间以外,还会伴随有少量额外的副作用,例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为。

code1:(配置 -verbose:gc)

  1. public static void main(String[] args){
  2.   byte[] placeholder = new byte[64 * 1024 * 1024];
  3.   System.gc();
  4. }

输出:

[GC (System.gc())  70788K->66416K(249344K), 0.0018694 secs]
[Full GC (System.gc())  66416K->66231K(249344K), 0.0078455 secs]

Full GC (System.gc()):箭头(->)前后的数据1224K和1113K分别表示垃圾收集GC前后所有存活对象使用的内存容量

上述代码并没有回收内存的64M空间,因为在执行System.gc()时,变量placeholder还处于作用域之内,虚拟机自然不敢回收掉placeholder的内存。

code2:

  1. public static void main(String[] args){
  2.   {
  3.       byte[] placeholder = new byte[64 * 1024 * 1024];
  4.   }
  5.   System.gc();
  6. }
输出:
[GC (System.gc())  70788K->66448K(249344K), 0.0009417 secs]
[Full GC (System.gc())  66448K->66231K(249344K), 0.0060159 secs]

显然还是没有回收,再改改代码。

code3:

  1. public static void main(String[] args){
  2.       {
  3.           byte[] placeholder = new byte[64 * 1024 * 1024];
  4.       }
  5.       int a = 0;
  6.       System.gc();
  7. }

输出:

[GC (System.gc())  70788K->66448K(249344K), 0.0014248 secs]
[Full GC (System.gc())  66448K->695K(249344K), 0.0057544 secs]

placeholder能否被回收的根本原因就是:局部变量表中的变量槽是否还存有关于placeholder数组对象的引用。第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,再没有发生过任何对局部变量表的读写操作,placeholder原本所占用的变量槽还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。这种关联没有被及时打断,绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存但实际上已经不会再使用的变量,手动将其设置为null值(用来代替那句int a=0,把变量对应的局部变量槽清空)便不见得是一个绝对无意义的操作,这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到即时编译器的编 译条件)下的“奇技”来使用。

b.操作数栈

每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈(Expression Stack) 。操作数栈在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push) /出栈(pop)。某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如:执行复制、交换、求和等操作。

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_ stack的值。 栈中的任何一个元素都是可以任意的Java数据类型。32bit的类型占用一个栈单位深度,64bit的类型占用两个栈单位深度。操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push)和出栈(pop) 操作来完成一次数据访问。

另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了。

c.动态连接

动态链接又称为指向运行时常量池方法的引用,每个栈侦内部都会包含一个指向运行时常量池中该栈侦所属方法的引用,即是知道我是谁。

在java源码被编译成字节码文件时,所有的变量和方法引用都作为符号引用保存在class文件的常量池(常量池在方法区中)里,比如 描述一个方法调用了另外的其他方法时,就是通过常量池中指向其他方法的符号引用来表示的,动态链接的作用就是为了将这些符号转为调用方法的实际直接引用。

之所以用常量池,一是能共享常量,不必每份都做存储。

而将符号转为调用方法的实际引用与方法的绑定机制有关,绑定机制分为: 静态链接:如果被调用的目标方法再编译期可知,且运行期间保持不变,则将这个转换过程称为静态链接 静态链接绑定的机制为早期绑定,是由于在编译时期便可知道目标方法的类型,就可以进行绑定

动态链接:如果被调用的目标方法无法在编译器可知,只能够在运行的时候将符号引用转换为直接引用,则称之为动态链接 动态链接绑定的机制为晚期绑定,因为只有在程序运行期间才能根据实际的类型再进行绑定。早期绑定其实是早期面向过程语言的方式,因为不支持多态性,所以只能实现早期绑定。

  1. /**
  2. * 说明早起绑定和晚期绑定的例子
  3. */
  4. public class AnnimalTest {
  5.   /**
  6.     * 不能在编译时知道动物是猫还是狗,晚期绑定
  7.     * @param animal
  8.     */
  9.   public void showAnimal(Animal animal){
  10.       animal.eat();
  11.   }
  12.   /**
  13.     * 不能在编译时知道捕猎的方式,晚期绑定
  14.     * @param hunt
  15.     */
  16.   public void showHunt(Huntable hunt){
  17.       hunt.hunt();
  18.   }
  19. }
  20. class Animal{
  21.   public void eat(){
  22.       System.out.println("动物进食");
  23.   }
  24. }
  25. interface Huntable{
  26.   void hunt();
  27. }
  28. class Dog extends Animal implements Huntable{
  29.   public void eat(){
  30.       System.out.println("狗吃骨头");
  31.   }
  32.   @Override
  33.   public void hunt() {
  34.       System.out.println("狗捉耗子,多管闲事");
  35.   }
  36. }
  37. class Cat extends Animal implements Huntable{
  38.   public Cat(){
  39.       super();
  40.   }
  41.   public Cat(String name){
  42.       this();
  43.   }
  44.   public void eat(){
  45.       System.out.println("猫吃老鼠");
  46.   }
  47.   @Override
  48.   public void hunt() {
  49.       System.out.println("猫捉老鼠");
  50.   }
  51. }

早期绑定:

晚期绑定:

d.方法返回地址

方法返回地址为存放该方法在寄存器中的值,也即是该方法的指令地址,方便执行引擎在执行完该方法后,回到该方法对应的指令行号,这样才能继续执行下去(因为当前方法执行完后,pc寄存器已经没有该方法的指令地址了)。

方法退出的方式主要有两种,分为正常退出和异常退出,下面来详细讲述:

执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口。一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。在字节码指令中,返回指令包含ireturn(返回值为boolean、byte、char、short和int类型时使用,前四种都可装成int)、lreturn(long)、freturn(float)、dreturn(double)、以及areturn(对象的引用类型),另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。

如果在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,且不会给上层调用者产生任何的返回值。方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到异常处理的代码。

实际上来说,方法的退出其实是对应当前栈侦出栈的过程,在当前栈侦出栈后,就需要恢复上层方法栈侦里的局部变量表,操作数栈,此时会将出栈的栈侦的返回值压入上层方法的操作数栈中,将方法返回地址设置进pc寄存器,以便让执行引擎继续执行下去。

演示案例:

  1. public class MyMethod {
  2.   public static void main(String[] args) {
  3.       new MyMethod().method1();
  4.   }
  5.   public void method1(){
  6.       System.out.println("进入method1 ...");
  7.       int a=1;
  8.       int b=2;
  9.       method2();
  10.       System.out.println("退出method1");
  11.   }
  12.   public void method2(){
  13.       System.out.println("进入method2 ...");
  14.       int c=3;
  15.       int d=4;
  16.       System.out.println("退出method2");
  17.   }
  18. }

对应一个简单的栈帧图

在数据结构中,栈属于一种典型的先进后出的数据结构,即先被压入栈的数据最后出来,对应到上面的案例中,Method2最后入栈,因此当Method2方法执行完毕后,最先从栈弹出,直到Main方法完成,当前这个线程的虚拟机栈就销毁,这也符合预期的方法调用返回结果。

在idea中通过安装jclasslib插件,可以清楚的看到方法的字节码运行过程中的详细信息,如下,我们分析method2这个方法在调用时的栈帧过程时候,通过显示的字节码信息可以看出来该方法中的局部变量信息,结果返回时机等。

e.附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在讨论概念时,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

(3)本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

(4)Java堆

堆针对一个JVM进程来说是唯一的,也就是一个进程只有一个JVM,但是进程包含多个线程,他们是共享同一堆空间的。

一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。堆内存的大小是可以调节的。《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。

-Xms10m:最小堆内存
-Xmx10m:最大堆内存

《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。从实际使用角度看,“几乎”所有的对象实例都在这里分配内存。因为还有一些对象是在栈上分配的,数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

a.对象的创建

执行new指令: 创建对象肯定要先执行new语句,当jvm执行到一条new指令的时候,会检查这个指令的参数能不能在常量池中定位到一个类的符号引用,并且检查这个符号引用对应的类有没有被加载、解析和初始化过

  • 如果没有,会先执行类加载过程

  • 如果有,就可以开始为要创建的对象在堆中分配内存空间了

分配内存空间: 分配内存空间就相当于分给这个对象块儿地方,在分配的时候有两种情况

  • 指针碰撞:如果在堆中的内存是规整的,也就是说一边是空闲可用的内存,一边是使用过的内存,那么它们中间就有一个指示器,象征着可用内存和已用内存的分界点,所以在给对象分空间的时候只需要把这个指示器向空闲内存那边移动一段和对象大小相等的距离即可。

  • 空闲列表:如果在堆中的内存是不规整的,即空闲内存和已用内存交错排列,那样就只能靠一个列表来记录哪些内存是空闲的了,当需要为对象分配内存空间的时候,只需要在列表中找出一个可用容纳下这个对象的内存空间并分配给这个对象即可,当分配完成后,需要在列表上更新记录。

  • 选择哪种方式由java堆自己来决定,但还是主要看堆中内存是否是规整的,而是否规整又取决于使用的是那种垃圾收集器,带有空间压缩整理功能的垃圾收集器在回收之后内存是规整的,此时可以使用指针碰撞,否则只能使用空闲列表了

线程安全问题 在多线程的环境下,创建对象的行为是很频繁的,可能jvm正在给对象A分配内存空间的时候,指示器还没有来得及更改,对象B也同时使用指示器来分配空间,这样就会造成线程不安全 为了解决这个问题,有两种方案:

  • 一种是对分配对象内存空间操作进行同步处理,虚拟机使用的是CAS加失败重配的方式来保证操作的原子性的

  • 另一种方式是对于不同线程,让其在不同空间上分配内存,即每个线程在Java堆中要分配一块属于这个线程的内存,被称为本地线程分配缓冲,哪个线程需要为对象分配空间,就在这个线程的本地缓冲区中分配,只有本地缓冲区用完了才会使用同步处理的方法

内存分配完成之后:

  • 分配完成之后,虚拟机会把分配到的内存空间都初始化为零值,保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

  • 之后,Jvm会对这个对象进行必要的设置,也就是添加属于这个对象的一些信息,这些信息都存放在对象头中。

  • 最后,还差构造函数没有执行,所有的字段都还是零值,其他的一些信息也还没有构造好,此时Jvm就会开始执行构造函数,按照程序员的意图来进行初始化,这时一个真正的对象才创建完毕

b.对象的内存布局

java 对象在堆中的内存布局划分为三个部分:对象头,实例数据, 对齐填充

  • 对象头存放两类数据。1:自身的运行时数据,2:类型指针。自身运行时的数据包括哈希码,GC分代年龄,锁标志状态,线程持有的锁,偏向线程ID,偏向时间戳,又成Mark Word。类型指针是用来存放java实例是指向哪个类。

  • 实例数据存放的是我们定义的各种类型的字段内容。

  • 对齐填充的数据是非必须的,没有特别的含义,对象在java内存中的布局必须是8字节的倍数,这其中java对象头的数据是设计为8字节的倍数的,但是实例数据是由用户定义的,不确定具体是多少,因此需要对齐填充部分,把这个对象填充成8字节的整数倍。

c.对象的访问定位

主流的访问方式主要有使用句柄和直接指针两种:句柄访问和直接指针访问。

如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图所示。

如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图示。

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被改。

(5)方法区

  • 方法区主要存放的是 Class,而堆中主要存放的是实例化的对象

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。

  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。

  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。

  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutofMemoryError。

    加载大量的第三方的jar包 Tomcat部署的工程过多(30~50个) 大量动态的生成反射类

  • 关闭JVM就会释放这个区域的内存。

    方法区,堆,虚拟机栈之间的关系

方法区的内部结构

它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

a.类型信息 对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVm必须在方法区中存储以下类型信息:

  • 这个类型的完整有效名称(全名=包名.类名)

  • 这个类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)

  • 这个类型的修饰符(public,abstract,final的某个子集)

  • 这个类型直接接口的一个有序列表

b.域信息 JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。

域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)

c.方法(Method)信息 JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称

  • 方法的返回类型(或void)

  • 方法参数的数量和类型(按顺序)

  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)

  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)

  • 异常表(abstract和native方法除外)

d.静态变量

静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分。类变量被类的所有实例共享,即使没有类实例时,你也可以访问它。

e.运行时常量池 VS 常量池

  • 方法区,内部包含了运行时常量池

  • 字节码文件,内部包含了常量池

  • 要弄清楚方法区,需要理解清楚C1assFile,因为加载类的信息都在方法区。

  • 要弄清楚方法区的运行时常量池,需要理解清楚classFile中的常量池。

常量池

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用。 一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。

如下代码:

  1. public class SimpleClass {
  2.   public void sayHello() {
  3.       System.out.println("hello");
  4.   }
  5. }

虽然上述代码只有194字节,但是里面却使用了String、System、PrintStream及Object等结构。这里的代码量其实很少了,如果代码多的话,引用的结构将会更多,这里就需要用到常量池了。

常量池中有什么?

  • 数量值

  • 字符串值

  • 类引用

  • 字段引用

  • 方法引用

常量池、可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。

运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些。当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛outofMemoryError异常。

JVM内存模型案例分析

  1. public class MethodAreaDemo {
  2.   public static void main(String args[]) {
  3.       int x = 500;
  4.       int y = 100;
  5.       int a = x / y;
  6.       int b = 50;
  7.       System.out.println(a+b);
  8.   }
  9. }

字节码执行过程展示

首先现将操作数500放入到操作数栈中

然后存储到局部变量表中

然后重复一次,把100放入局部变量表中,最后再将变量表中的500 和 100 取出,进行操作

将500 和 100 进行一个除法运算,在把结果入栈

在最后就是输出流,需要调用运行时常量池的常量

最后调用invokevirtual(虚方法调用),然后返回

返回时

程序计数器始终计算的都是当前代码运行的位置,目的是为了方便记录 方法调用后能够正常返回,或者是进行了CPU切换后,也能回来到原来的代码进行执行。

2,内存溢出

首先在IDEA中设置虚拟机启动参数。

-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

-verbose:gc 在控制台输出GC情况

-XX:+PrintGCDetails 在控制台输出详细的GC情况

-Xms20M 表示设置jvm堆的最小值为20M

-Xmx20M 表示设置JVM堆的最大值为20M

-Xmn10M 表示轻年代空间大小为10m,即老年代空间大小为50 - 10 = 40m

-XX:SurvivorRatio=8 Survivor和Edon的比例是8:1:1

(1)堆溢出

Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径 来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会 产生内存溢出异常。

通过设置参数-XX:+HeapDumpOnOutOf-MemoryError可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析。

  1. /**
  2. * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
  3. */
  4. import java.util.ArrayList;
  5. import java.util.List;
  6. public class Test234 {
  7.    static class OOMObject {
  8.   }
  9.    public static void main(String[] args){
  10.        List<OOMObject> list = new ArrayList<OOMObject>();
  11.        while (true) {
  12.            list.add(new OOMObject());
  13.       }
  14.   }
  15. }

运行结果:

  1. java.lang.OutOfMemoryError: Java heap space
  2. Dumping heap to java_pid26948.hprof ...
  3. Heap dump file created [28272464 bytes in 0.070 secs]
  4. Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
  5. at java.util.Arrays.copyOf(Arrays.java:3210)
  6. at java.util.Arrays.copyOf(Arrays.java:3181)
  7. at java.util.ArrayList.grow(ArrayList.java:261)
  8. at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
  9. at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
  10. at java.util.ArrayList.add(ArrayList.java:458)
  11. at Test234.main(Test234.java:12)

要解决这个内存区域的异常

第一步首先应确认内存中导致OOM(内存溢出)的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。使用IDEA打开的堆转储快照文件。

进入JDK安装目录

 双击jvisualvm.exe

点击装入快照

文件类型选择堆,文件路径参考上面异常信息中打印的文件路径。

如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。

如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

(2)虚拟机栈和本地方法栈溢出

由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数来设定。关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:

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

2)如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

HotSpot虚拟机是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。

为了验证这点,我们可以做两个实验,先将实验范围限制在单线程中操作,尝试下面两种行为是否能让HotSpot虚拟机产生OutOfMemoryError异常:

第一个实验:使用-Xss参数减少栈内存容量,结果抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。

  1. /**
  2. * VM Args:VM Args:-Xss128k
  3. */
  4. public class Test234 {
  5.    private int stackLength = 1;
  6.    public void stackLeak() {
  7.        stackLength++;
  8.        stackLeak();
  9.   }
  10.    public static void main(String[] args){
  11.        Test234 oom = new Test234();
  12.        try {
  13.            oom.stackLeak();
  14.       } catch (Throwable e) {
  15.            System.out.println("stack length:" + oom.stackLength);
  16.            throw e;
  17.       }
  18.   }
  19. }
 

运行结果

  1. stack length:991
  2. Exception in thread "main" java.lang.StackOverflowError
  3. at Test234.stackLeak(Test234.java:7)
  4. at Test234.stackLeak(Test234.java:8)
  5. ......

第二个实验:定义了大量的本地变量,增大此方法帧中本地变量表的长度。结果抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。

  1. /**
  2. * VM Args:VM Args:
  3. */
  4. public class Test234 {
  5.    private static int stackLength = 0;
  6.    public static void test() {
  7.        long unused1, unused2, unused3, unused4, unused5,
  8.                unused6, unused7, unused8, unused9, unused10,
  9.                unused11, unused12, unused13, unused14, unused15,
  10.                unused16, unused17, unused18, unused19, unused20,
  11.                unused21, unused22, unused23, unused24, unused25,
  12.                unused26, unused27, unused28, unused29, unused30,
  13.                unused31, unused32, unused33, unused34, unused35,
  14.                unused36, unused37, unused38, unused39, unused40,
  15.                unused41, unused42, unused43, unused44, unused45,
  16.                unused46, unused47, unused48, unused49, unused50,
  17.                unused51, unused52, unused53, unused54, unused55,
  18.                unused56, unused57, unused58, unused59, unused60,
  19.                unused61, unused62, unused63, unused64, unused65,
  20.                unused66, unused67, unused68, unused69, unused70,
  21.                unused71, unused72, unused73, unused74, unused75,
  22.                unused76, unused77, unused78, unused79, unused80,
  23.                unused81, unused82, unused83, unused84, unused85,
  24.                unused86, unused87, unused88, unused89, unused90,
  25.                unused91, unused92, unused93, unused94, unused95,
  26.                unused96, unused97, unused98, unused99, unused100;
  27.        stackLength++;
  28.        test();
  29.        unused1 = unused2 = unused3 = unused4 = unused5 = unused6 = unused7 = unused8 = unused9 = unused10 =
  30.        unused11 = unused12 = unused13 = unused14 = unused15 = unused16 = unused17 = unused18 = unused19 = unused20 =
  31.        unused21 = unused22 = unused23 = unused24 = unused25 = unused26 = unused27 = unused28 = unused29 = unused30 =
  32.        unused31 = unused32 = unused33 = unused34 = unused35 = unused36 = unused37 = unused38 = unused39 = unused40 =
  33.        unused41 = unused42 = unused43 = unused44 = unused45 = unused46 = unused47 = unused48 = unused49 = unused50 =
  34.        unused51 = unused52 = unused53 = unused54 = unused55 = unused56 = unused57 = unused58 = unused59 = unused60 =
  35.        unused61 = unused62 = unused63 = unused64 = unused65 = unused66 = unused67 = unused68 = unused69 = unused70 =
  36.        unused71 = unused72 = unused73 = unused74 = unused75 = unused76 = unused77 = unused78 = unused79 = unused80 =
  37.        unused81 = unused82 = unused83 = unused84 = unused85 = unused86 = unused87 = unused88 = unused89 = unused90 =
  38.        unused91 = unused92 = unused93 = unused94 = unused95 = unused96 = unused97 = unused98 = unused99 = unused100 = 0;
  39.   }
  40.     public static void main(String[] args){
  41.          try {
  42.              test();
  43.         }catch (Error e){
  44.              System.out.println("stack length:" + stackLength);
  45.              throw e;
  46.         }
  47.   }
  48. }

运行结果

  1. stack length:7305
  2. Exception in thread "main" java.lang.StackOverflowError
  3. at Test234.test(Test234.java:28)
  4. at Test234.test(Test234.java:28)
  5. ......

实验结果表明:无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常.

如果测试时不限于单线程,通过不断建立线程的方式,在HotSpot上也是可以产生内存溢出异常的,但是这样产生的内存溢出异常和栈空间是否足够并不存在任何直接的关系,主要取决于操作系统本身的内存使用状态。甚至可以说,在这种情况下,给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。

原因其实不难理解,操作系统分配给每个进程的内存是有限制的,譬如64位Windows的单个进程最大内存限制约为3.2GB。HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值,那剩余的内存即为3.2GB(操作系统限制)减去最大堆容量,再减去最大方法区容量,由于程序计数器消耗内存很小,可以忽略掉,如果把直接内存和虚拟机进程本身耗费的内存也去掉的话,剩下的内存就由虚拟机栈和本地方法栈来分配了。因此为每个线程分配到的栈内存越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。

请不要轻易尝试运行下面代码,容易卡死!!!

  1. /**
  2. * VM Args:VM Args:-Xss2M 设置每个线程的栈大小,减小这个值,可以使虚拟机创建更多线程
  3. */
  4. public class Test234 {
  5.    private void dontStop() {
  6.        while (true) {
  7.       }
  8.   }
  9.    public void stackLeakByThread() {
  10.        while (true) {
  11.            Thread thread = new Thread(new Runnable() {
  12.                @Override
  13.                public void run() {
  14.                    dontStop();
  15.               }
  16.           });
  17.            thread.start();
  18.       }
  19.   }
  20.    public static void main(String[] args){
  21.        Test234 oom = new Test234();
  22.        oom.stackLeakByThread();
  23.   }
  24. }

运行结果

Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread

出现StackOverflowError异常时,会有明确错误堆栈可供分析,相对而言比较容易定位到问题所在。如果使用HotSpot虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说大多数情况下)到达1000~2000是完全没有问题,对于正常的方法调用(包括不能做尾递归优化的递归调用),这个深度应该完全够用了。但是,如果是建立过多线程导致的内存溢出,在不能减少线程数量或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。这种通过“减少内存”的手段来解决内存溢出的方式,如果没有这方面处理经验,一般比较难以想到,这一点读者需要在开发32位系统的多线程应用时注意。也是由于这种问题较为隐蔽,从JDK 7起,以上提示信息中“unable to create native thread”后面,虚拟机会特别注明原因可能是“possibly out of memory or process/resource limits reached”。

(3)方法区和运行时常量池溢出

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。String:intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。

在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的容量。

  1. /**
  2. * VM Args:-XX:PermSize=6M -XX:MaxPermSize=6M
  3. * @author zzm
  4. */
  5. public class RuntimeConstantPoolOOM {
  6.    public static void main(String[] args) {
  7.        // 使用Set保持着常量池引用,避免Full GC回收常量池行为
  8.        Set<String> set = new HashSet<String>();
  9.        // 在short范围内足以让6MB的PermSize产生OOM了
  10.        short i = 0;
  11.        while (true) {
  12.            set.add(String.valueOf(i++).intern());
  13.       }
  14.   }
  15. }

运行结果

  1. Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
  2.    at java.lang.String.intern(Native Method)
  3.    at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java: 18)

从运行结果中可以看到,运行时常量池溢出时,在OutOfMemoryError异常后面跟随的提示信息 是“PermGen space”,说明运行时常量池的确是属于方法区(即JDK 6的HotSpot虚拟机中的永久代)的 一部分。

JDK 7或更高版本的JDK来运行这段程序并不会得到相同的结果,无论是在JDK 7中继续使用-XX:MaxPermSize参数或者在JDK 8及以上版本使用-XX:MaxMeta-spaceSize参数把方法区容量同样限制在6MB,也都不会重现JDK 6中的溢出异常,循环将一直进行下去,永不停歇。出现这种变化,是因为自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中,所以在JDK 7及以上版本,限制方法区的容量对该测试用例来说是毫无意义的。这时候使用-Xmx参数限制最大堆到6MB就能够看到以下两种运行结果之一,具体取决于哪里的对象分配时产生了溢出:

  1. // OOM异常一:
  2. Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
  3.    at java.base/java.lang.Integer.toString(Integer.java:440)
  4.    at java.base/java.lang.String.valueOf(String.java:3058)
  5.    at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12)
  6. // OOM异常二:
  7. Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
  8.    at java.base/java.util.HashMap.resize(HashMap.java:699)
  9.    at java.base/java.util.HashMap.putVal(HashMap.java:658)
  10.    at java.base/java.util.HashMap.put(HashMap.java:607)
  11.    at java.base/java.util.HashSet.add(HashSet.java:220)
  12.    at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java from InputFile-Object:14)
 

很显然是堆内存泄露

String.intern()返回引用的测试

  1. public class Test234 {
  2.    public static void main(String[] args){
  3.        String str1 = new StringBuilder("计算机").append("软件").toString();
  4.        System.out.println(str1.intern() == str1);
  5.        String str2 = new StringBuilder("ja").append("va").toString();
  6.        System.out.println(str2.intern() == str2);
  7.   }
  8. }

这段代码在JDK 6中运行,会得到两个false,而在JDK 7中运行,会得到一个true和一个false。产生差异的原因是,在JDK 6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在Java堆上,所以必然不可能是同一个引用,结果将返回false。 而JDK 7(以及部分其他虚拟机,例如JRockit)的intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。而对str2比较返回false,这是因为“java”这个字符串在执行String-Builder.toString()之前就已经出现过了,字符串常量池中已经有它的引用,不符合intern()方法要求“首次遇到”的原则,“计算机软件”这个字符串则是首次出现的,因此结果返回true。

方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这部分区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出为止。

  1. /**
  2. * VM Args:VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
  3. */
  4. public class Test234 {
  5.    public static void main(String[] args){
  6.        while (true) {
  7.            Enhancer enhancer = new Enhancer();
  8.            enhancer.setSuperclass(OOMObject.class);
  9.            enhancer.setUseCache(false);
  10.            enhancer.setCallback(new MethodInterceptor() {
  11.                public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
  12.                    return proxy.invokeSuper(obj, args);
  13.               }
  14.           });
  15.            enhancer.create();
  16.       }
  17.   }
  18.    static class OOMObject {
  19.   }
  20. }

运行结果

  1. Caused by: java.lang.OutOfMemoryError: PermGen space
  2. at java.lang.ClassLoader.defineClass1(Native Method)
  3. at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632)
  4. at java.lang.ClassLoader.defineClass(ClassLoader.java:616)
  5. ... 8 more

方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这类场景除了之前提到的程序使用了CGLib字节码增强和动态语言外,常见的还有:大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。 在JDK 8以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。在默认设置下,前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。

不过为了让使用者有预防实际应用里出现类似于代码清单2-9那样的破坏性的操作,HotSpot还是提供了一 些参数作为元空间的防御措施,主要包括:

  • -XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。

  • -XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。

  • -XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。

(4)本机直接内存溢出

直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致,直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能,在JDK 10时才将Unsafe的部分功能通过VarHandle开放给外部使用),因为虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory()。

  1. import sun.misc.Unsafe;
  2. import java.lang.reflect.Field;
  3. /**
  4. * VM Args:VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
  5. */
  6. public class Test234 {
  7.    private static final int _1MB = 1024 * 1024;
  8.    public static void main(String[] args) throws IllegalAccessException {
  9.        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
  10.        unsafeField.setAccessible(true);
  11.        Unsafe unsafe =(Unsafe)unsafeField.get(null);
  12.        while (true) {
  13.            unsafe.allocateMemory(_1MB);
  14.       }
  15.   }
  16.    static class OOMObject {
  17.   }
  18. }

运行结果

  1. Exception in thread "main" java.lang.OutOfMemoryError
  2. at sun.misc.Unsafe.allocateMemory(Native Method)
  3. at Test234.main(Test234.java:15)

由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。

3,垃圾回收

 

(1)判断对象是否已经死亡

1)引用计数器

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。Java虚拟机里面都没有选用引用计数算法来管理内存,因为单纯的引用计数很难解决对象之间相互循环引用的问题。

2)可达性分析算法

通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

 

对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。

  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。

  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。

  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

  • 所有被同步锁(synchronized关键字)持有的对象。

  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

3)引用(了解)

在JDK 1.2版之后,Java将引用分为强引用、软引用、弱引用和虚引用4种,这4种引用强度依次逐渐减弱。

  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

  • 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。

  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。

  • 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。

4)二次标记

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。

如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记。随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

  1. /**
  2. * 此代码演示了两点:
  3. * 1.对象可以在被GC时自我拯救。
  4. * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
  5. */
  6. public class FinalizeEscapeGC {
  7.   public static FinalizeEscapeGC SAVE_HOOK = null;
  8.   public void isAlive() {
  9.       System.out.println("yes, i am still alive :)");
  10.   }
  11.   @Override
  12.   protected void finalize() throws Throwable {
  13.       super.finalize();
  14.       System.out.println("finalize method executed!");
  15.       FinalizeEscapeGC.SAVE_HOOK = this;
  16.   }
  17.   public static void main(String[] args) throws Throwable {
  18.       SAVE_HOOK = new FinalizeEscapeGC();
  19. //对象第一次成功拯救自己
  20.       SAVE_HOOK = null;
  21.       System.gc();
  22. // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
  23.       Thread.sleep(500);
  24.       if (SAVE_HOOK != null) {
  25.           SAVE_HOOK.isAlive();
  26.       } else {
  27.           System.out.println("no, i am dead :(");
  28.       }
  29. // 下面这段代码与上面的完全相同,但是这次自救却失败了
  30.       SAVE_HOOK = null;
  31.       System.gc();
  32. // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
  33.       Thread.sleep(500);
  34.       if (SAVE_HOOK != null) {
  35.           SAVE_HOOK.isAlive();
  36.       } else {
  37.           System.out.println("no, i am dead :(");
  38.       }
  39.   }
  40. }
 

运行结果

  1. finalize method executed!
  2. yes, i am still alive :)
  3. no, i am dead :(

SAVE_HOOK对象的finalize()方法确实被垃圾收集器触发过,并且在被收集前成功逃脱了。另外一个值得注意的地方就是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败了。这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。不鼓励大家使用这个方法来拯救对象。

5)回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似。 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。

  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。

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

(2)垃圾收集算法

1)分代收集理论

分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

a 弱分代假说:绝大多数对象都是朝生夕灭的。

b 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

c 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

2)标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

 

它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

3)标记-复制算法

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

 

HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了'Appel式回收'来设计新生代的内存布局。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

3)标记-整理算法

针对老年代对象的存亡特征,提出了“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,“

 

  • 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行.'Stop The World'

  • 如果不移动存活对象,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的。

还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

4,类加载机制

5,调优

二,JUC专题

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/凡人多烦事01/article/detail/446122
推荐阅读
相关标签
  

闽ICP备14008679号