Java 虚拟机(JVM)学习笔记

        代码编译的结果从本地机器指令码转化为字节码,是存储格式发展的一小步,但却是编程语言发展的一大步

​                                                                                         —— 《深入理解JVM虚拟机》周志明·著

1.JVM体系结构

 其中,Java栈、本地方法栈、程序计数器不会有垃圾回收

99%的JVM调优,调的是方法区和堆

Java虚拟机将描述类的数据从class字节码文件加载到内存,并且对数据进行校验,转化,解析,初始化的工作,最终形成在内存中可以直接使用的数据类型。 这个过程叫做虚拟机的类加载机制。

2.类加载子系统(Class Loader)

类加载的时机

关于类加载的时机,《Java虚拟机规范》中并没有明确规定。这点可以由虚拟机的具体实现决定。

但是类的初始化阶段,规范中明确规定当某个类没有进行初始化,只有以下6中情况才会触发其初始化过程。

  1. 遇到new,getStaticputStatic,  invokeStatic,这四条字节码指令的时候,如果改类型没有进行初始化,则会触发其初始化。也就是如下情况: 遇到new关键字进行创建对象的时候。 读取或者设置一个类的静态字段的时候(必须被final修饰,也就是在编译器把结果放入常量池中)。 调用一个类的静态方法的时候。
  2. 使用java.lang.reflect进行反射调用的时候。
  3. 当初始化某个类,发现其父类没有初始化的时候。
  4. 当虚拟机启动的时候,会触发其主方法所在的类进行初始化。
  5. 当使用JDK1.7中的动态语言支持时,如果一个java.lang.invoke.MethidHandle实例最后的解析结果为REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial四种类型的方法句柄,并且这个句柄对应的类没有被初始化。
  6. 当一个接口实现了JDK1.8中的默认方法的时候,如果这个接口的实现类被初始化,则该接口要在其之前进行实例化。

对于以上6中触发类的初始化条件,在JVM规范中有一个很强制的词,if and only if (有且只有)。这六种行为被称为对类进行主动引用,除此之外,其他引用类的方式均不会触发类的初始化。

作用:加载Class文件

类加载子系统负责从文件系统或者网络中加载Class文件(Class文件在开头有特定标识)。

类加载器(Class Loader)只负责class文件的加载,至于是否可以运行,由执行引擎(Execution Engine)决定。

加载的类信息存放于一块成为方法区的内存空间。除了类信息之外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

Car.class存放于本地硬盘中,在运行的时候,JVM将Car.class文件加载到JVM中,被称为DNA元数据模板

存放在JVM的方法区中,之后根据元数据模板实例化出相应的对象。

在 .class -> JVM -> 元数据模板 -> 实例对象 这个过程中,类加载器扮演者快递员的角色。

加载器种类

1.虚拟机自带的加载器

2.启动类(根)加载器

负责加载JAVA_HOME/lib目录下的可以被虚拟机识别(通过文件名称,比如rt.jar``tools.jar)的字节码文件。与之对应的是java.lang.ClassLoader

3.扩展类加载器(ExtClassLoader)

负责加载JAVA_HOME/lib/ext目录下的的字节码文件。

对应sun.misc.Launcher类 此类继承于启动类加载器ClassLoader

4.应用程序加载器(AppClassLoader)

负责加载ClassPath路径下的字节码 也就是用户自己写的类。

对应于sun.misc.Launcher.AppClassLoader类 此类继承于扩展类加载器Launcher

加载的时候,一层一层的往上找

类加载的过程主要分为三个阶段 加载,链接,初始化。 而链接阶段又可以细分为验证,准备,解析三个子阶段。

加载过程

需要完成以下三个事情:

  • 通过一个类的全限定名获取定义此类的二进制字节流

  • 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构

  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

加载结束之后,外部的二进制字节流就会以JVM所设定的格式存在于方法区中了。 之后会在堆中实例一个java.lang.class类型的对象, 这个对象作为程序访问方法区中的类型数据的入口。

链接过程

验证(Verify)

目的:

在于确保Class文件的字节流中包含信息符合当前JVM规范要求,保证被加载类的正确性,不会危害虚拟机自身安全。

主要包括四种验证

1.文件格式验证

        字节码是否以十六进制的CAFEBABE开头

        主,次版本号是否在当前虚拟机可接受的范围之内。

        常量池的常量中是否有不被支持的类型

        Class文件中是否有被添加的其他恶意信息。

        文件格式验证不止以上,上面所列举的只是从HotSpot虚拟机源码中摘抄的一部分。只有通过这个阶段的验证之后,这一段字节流才会进入虚拟机内存中进行存储, 之后的过程都是基于方法区中的存储结构进行的。不会直接读取字节流了。

2.源数据验证

        用于保证字节码中的代码符合《Java语言规范》

        此类的父类是否是不可继承的类(Final修饰的)

        如果此类不是抽象类,它是否实现了全部需要实现的方法。

        类中的字段,方法是否和父类冲突。

3.字节码验证

        此过程保证代码是符合逻辑的,对代码的流程进行判断,保证不会出现危害虚拟机安全的情况。保证任意时刻操作数栈中的类型和指令代码序列可以正常工作,比如执行到iadd字节码指令,但是操作数栈顶有一位是Long类型的。保证代码中的类型转换是有效的。如果一个类型中的方法体没有通过次阶段,那它一定是有问题的。但是,不可以认为只要通过此阶段验证,一定没有问题。通过程序去校验程序的逻辑是无法做到绝对准确的。

4.符号引用验证

此阶段验证符号引用是否合法,主要用于解析阶段的前置任务。

主要用于判断 该类中是否存在缺少后者被禁止访问它依赖的某些外部类,字段,方法等资源。

准备(Prepare)

为类变量(static)分配内存并且设置初始值。

这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;

不会为实例变量分配初始化,类变量会分配在方法去中,而实例变量是会随着对象一起分配到java堆中。

解析(Resolve)

将常量池内的符号引用转换为直接引用的过程。

事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行

符号引用就是一组符号来描述所引用的目标。符号应用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info/CONSTANT_Fieldref_info、CONSTANT_Methodref_info等。

初始化过程

初始化阶段就是执行类构造器方法clInit()的过程。clInit是ClassInit缩写。

此方法并不是程序员定义的构造方法。是javac编译器自动收集类中的所有类变量(Static)的赋值动作和静态代码块中的语句合并而来。

构造器方法中指令按语句在源文件中出现的顺序执行

若该类具有父类,jvm会保证子类的clinit()执行前,父类的clinit()已经执行完毕

虚拟机必须保证一个类的clinit()方法在多线程下被同步加锁。

双亲委派机制

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成的class对象。而且加载某个类的class文件时,java虚拟机采用的是双亲委派模式。即把请求交由父类处理,它是一种任务委派模式

1.如果一个类加载器收到了类加载的请求,它并不会自己加载,而是先把请求委托给父类的加载器执行

2.如果父类加载器还有父类,则进一步向上委托,依次递归,请求到达最顶层的引导类加载器。

3.如果顶层类的加载器加载成功,则成功返回。如果失败,则子加载器会尝试加载。直到加载成功。

沙箱安全机制

沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源的访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。

最新的安全机制:虚拟机会把所有代码加载到不同的系统域和应用域,系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域,对应不同的权限。

自定义String类,但是在加载自定义String类的时候会率先使用引导类加载器加载, 而引导类加载器在加载过程中会先加载jdk自带的文件(rt.jar包中的java\lang\String.class), 报错信息说没有main方法就是因为加载的是rt.jar包中的String类。 这样可以保证对java核心源代码的保护,这就是沙箱安全机制.

3.运行时数据区内部结构

java虚拟机定了了若干种程序运行期间会使用到的运行时数据区

其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁,红色区域部分

另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。灰色区域部分

线程是一个程序里的运行单元,JVM允许一个程序有多个线程并行的执行;

在HotSpot JVM,每个线程都与操作系统的本地线程直接映射

当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。java线程执行终止后。本地线程也会回收; 操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用java线程中的run()方法

native关键字

凡是带native关键字的方法 说明Java的作用范围达不到了,调用底层C语言的库;

会进入本地方法栈,调用本地方法接口(JNI)扩展Java的使用,融合不同的编程语言为Java所用

在内存中专门开辟了一块标记区域:Native Method Stack(本地方法栈),登记native方法

程序计数器

PC寄存器是用来存储指向下一条指令的地址,也即将将要执行的指令代码。由执行引擎读取下一条指令。

它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域

在jvm规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致

任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined)。

它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成

字节码解释器工作时就是通过改变这个计数器的值来选取下一跳需要执行的字节码指令

它是唯一一个在java虚拟机规范中没有规定任何OOM(OutOfMemoryError)情况的区域

1.使用PC寄存器存储字节码指令地址有什么用呢?

因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行,JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令

2.PC寄存器为什么会设定为线程私有?

我们都知道所谓的多线程在一个特定的时间段内指回执行其中某一个线程的方法,CPU会不停的做任务切换,这样必然会导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

虚拟机栈

Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应这个一次次的java方法调用。它是线程私有的

生命周期和线程是一致的

作用:主管java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。

局部变量:相对于成员变量(或属性)

基本数据变量: 相对于引用类型变量(类,数组,接口)

栈的特点

栈是一种快速有效的分配存储方式,访问速度仅次于PC寄存器(程序计数器)

JVM直接对java栈的操作只有两个,每个方法执行,伴随着进栈(入栈,压栈),执行结束后的出栈工作

对于栈来说不存在垃圾回收问题

栈的运行原理

每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在

在这个线程上正在执行的每个方法都对应各自的一个栈帧,栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。JVM直接对java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循先进后出/后进先出的和原则。

在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method)

执行引擎运行的所有字节码指令只针对当前栈帧进行操作

如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前栈帧。不同线程中所包含的栈帧是不允许相互引用的,即不可能在另一个栈帧中引用另外一个线程的栈帧

如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧

Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。

native关键字(本地方法栈)

凡是带native关键字的方法 说明Java的作用范围达不到了,调用底层C语言的库;

会进入本地方法栈,调用本地方法接口(JNI)扩展Java的使用,融合不同的编程语言为Java所用

在内存中专门开辟了一块标记区域:Native Method Stack(本地方法栈),登记native方法,在执行时调用JNI接口

native作用

与java环境外交互:有时java应用需要与java外面的环境交互,这是本地方法存在的主要原因。 你可以想想java需要与一些底层系统,如擦偶偶系统或某些硬件交换信息时的情况。本地方法正式这样的一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解java应用之外的繁琐细节。

与操作系统交互:JVM支持着java语言本身和运行库,它是java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用java实现了jre的与底层系统的交互,甚至jvm的一些部分就是用C写的。还有,如果我们要使用一些java语言本身没有提供封装的操作系统特性时,我们也需要使用本地方法。

Sun’s Java:Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分是用java实现的,它也通过一些本地方法与外界交互。例如:类java.lang.Thread的setPriority()方法是用Java实现的,但是它实现调用的事该类里的本地方法setPriority0()。这个本地方法是用C实现的,并被植入JVM内部,在Windows 95的平台上,这个本地方法最终将调用Win32 SetProority()API。这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVM调用。

Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用

本地方法栈,也是线程私有的。

允许被实现成固定或者是可动态拓展的内存大小。(在内存溢出方面是相同的)如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverFlowError异常。

如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么java虚拟机将会抛出一个OutOfMemoryError异常。

本地方法是使用C语言实现的,它的具体做法是在虚拟机栈中登记native方法,在Execution Engine执行时加载本地方法库。当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,它甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存;并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。

在hotSpot JVM中,直接将本地方法栈和虚拟机栈合二为一。

一个进程对应一个jvm实例,同时包含多个线程,这些线程共享方法区和堆,每个线程独有程序计数器、本地方法栈和虚拟机栈。

一个jvm实例只存在一个堆内存,堆也是java内存管理的核心区域

Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间(堆内存的大小是可以调节的)《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的

所有的线程共享java堆,在这里还可以划分线程私有的缓冲区(TLAB:Thread Local Allocation Buffer).(面试问题:堆空间一定是所有线程共享的么?不是,TLAB线程在堆中独有的)

《Java虚拟机规范》中对java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。

从实际使用的角度看,“几乎”所有的对象的实例都在这里分配内存 (‘几乎’是因为可能存储在栈上)数组或对象永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置

在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除

堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域

堆的大小是可以调节的

年轻代和老年代

存储在JVM中的java对象可以被划分为两类:

一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速(存入新生代)

另外一类对象时生命周期非常长,在某些情况下还能与JVM的生命周期保持一致 (存入老年代)

Java堆区进一步细分可以分为年轻代(YoungGen)和老年代(OldGen)其中年轻代可以分为伊甸园区(Eden)、新生区1(from)和新生区2(to)

对象分配过程

GC垃圾回收主要在伊甸园区和养老区

所有对象都是在伊甸园区new出来的

new的对象先放伊甸园区。此区有大小限制。

当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。将伊甸园中的剩余对象移动到幸存者0区。

然后加载新的对象放到伊甸园区

如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。

如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。

啥时候能去养老区呢?可以设置次数。默认是15次,可以设置参数。

在养老区,相对悠闲。当老年区内存不足时,再次触发GC:Major GC,进行养老区的内存清理。

若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常

永久区 

堆空间分代思想

为什么要把Java堆分代?不分代就不能正常工作了么

经研究,不同对象的生命周期不同。70%-99%的对象都是临时对象。

新生代:有Eden、Survivor构成(s0,s1 又称为from to),to总为空

老年代:存放新生代中经历多次依然存活的对象

其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描,而很多对象都是朝生夕死的。如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

注:TLAB

TLAB的全称是Thread Local Allocation Buffer,翻译过来就是线程本地分配缓存。

TLAB在Eden区,因为eden区一般是新建对象所在的区域(这里去除大对象,因为大对象会直接进入老年代)

首先从Thread Local这两个单词能够联想到一个本地线程变量类ThreadLocal,该类可以用来维护线程私有变量,而TLAB则是一个线程专用的内存分配区域,也是线程私有的。
在日常的业务过程中,Java对象会不断的被新建和不断的被回收,这就涉及到对象的分配了,而新建的对象一般都是分配在堆上,而堆却是线程共享的。所以如果同一时间,有多个线程要在堆上申请空间,这里可以类比多线程访问共享变量的操作,要保证共享变量的线程安全,就得采取线程安全的手段。所以每一次对象分配都要做同步,而越多的线程要在堆上申请空间,竞争就会越激烈,效率就会降低。因此Java虚拟机采用了TLAB这种线程专属的区域来避免出现多线程冲突,提高对象分配的效率。TLAB是默认启动的,在该情况下,JAVA虚拟机会为每一个线程都分配一个TLAB区域。

方法区

Java虚拟机规范中明确说明:‘尽管所有的方法区在逻辑上属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。’但对于HotSpotJVM而言,方法区还有一个别名叫做Non-heap(非堆),目的就是要和堆分开。所以,方法区可以看作是一块独立于Java堆的内存空间。

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

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

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

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

比如:

加载大量的第三方jar包;

Tomcat部署的工程过多;

大量动态生成反射类;

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

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

类型信息

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

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

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

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

④这个类型直接实现接口的一个有序列表

域信息(成员变量/属性)

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

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

方法信息

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

方法名称

方法的返回类型(或void)

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

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

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

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

每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

non-final的类变量

静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分

类变量被类的所有实例所共享,即使没有类实例你也可以访问它。全局常量 static final 在编译的时候就被分配赋值了。


4.GC回收

什么是垃圾( Garbage) 呢?

垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。 如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空 间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。

垃圾收集,不是Java语言的伴生产物。早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。

关于垃圾收集有三个经典问题:

哪些内存需要回收?

什么时候回收?

如何回收?

垃圾收集机制是Java的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。

Java垃圾回收机制

自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险

自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发

对于Java开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。

垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。

其中,Java堆是垃圾收集器的工作重点。

从次数上讲:

频繁收集Young区

较少收集0ld区

基本不动Perm区(方法区)

垃圾回收算法

引用计数法

引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型 的引用计数器属性。用于记录对象被引用的情况。

对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。

优点

  • 实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。

缺点

  • 需要单独的字段存储计数器,这样的做法增加了存储空间的开销。

  • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。

  • 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一 条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。

复制算法

可达性分析算法是以根对象(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。

使用可达性分析算法之后,内存中存活的对象都会被根对象集合直接或者间接连接,搜索走过的路径叫做引用链。如果目标对象没有任何引用链相连,则表示不可达,为垃圾。

Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。

当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。永远不要主动调用某个对象的finalize ()方法,应该交给垃圾回收机制调用。理由包括下面三点:

在finalize()时可能会导致对象复活。

finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。

一个糟糕的finalize()会严重影响GC的性能。

从功能上来说,finalize()方法与C++ 中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质,上不同于C++ 中的析构函数。

对象是否"死亡

由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。

如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:

可触及的:从根节点开始,可以到达这个对象。

可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。

不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一一次。

以上3种状态中,是由于finalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。

判定是否可以回收具体过程

如果对象objA到GC Roots没有引用链,则进行第一次标记。

进行筛选,判断此对象是否有必要执行finalize()方法

①如果对象objA没有重写finalize()方法,或者finalize ()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。

②如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F一Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。

③finalize()方法是对象逃脱死亡的最后机会,稍后Gc会对F一Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。 在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。

优点:

没有标记和清除过程,实现简单,==运行高效==

复制过去以后保证==空间连续性==,不会出现“碎片”问题。

缺点:

此算法的缺点也是很明显的,就是需要两倍的内存空间。

特别的如果系统中的可用对象很多,复制算法不会很理想,因为要复制大量的对象

在新生代,对常规应用的垃圾回收,一次通常可以回收70一99的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。

标记清除压缩算法

清除:

当堆中的有效内存空间被耗尽时,就会停止程序STW,然后进行标记清除

  • 标记:Collector从引用的根节点开始遍历,标记所有的被引用的对象,在对象的对象头中记录为可达对象
  • 清除:将对象头中没有标记为可达对象的对象进行清除

优点:

常用,简单

缺点

效率不算高(两次O(n)的扫描)

在进行GC的时候,需要停止整个应用程序,导致用户体验差

这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表

何为清除?

这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。

清除压缩:

执行过程

  • 第一阶段和标记一清除算法一样,从根节点开始标记所有被引用对象.
  • 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
  • 之后,清理边界外所有的空间。

标记一压缩算法的最终效果等同于标记一清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记一清除一压缩(Mark一 Sweep一Compact)算法。

二者的本质差异在于标记清除算法是一种非移动式的回收算法,标记压.缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。

可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

指针碰撞(Bump the Pointer )

如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump the Pointer)。

优点

消除了标记一清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只 需要持有一个内存的起始地址即可。

消除了复制算法当中,内存减半的高额代价。

缺点

从效率.上来说,标记一整理算法要低于复制算法。

移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。

移动过程中,需要全程暂停用户应用程序。即:STW

什么是STW?

在STW 状态下,JAVA的所有线程都是停⽌执⾏的 -> GC线程除外
一旦Stop-the-world发生,除了GC所需的线程外,其他线程都将停止工作,中断了的线程直到GC任务结束才继续它们的任务。
STW是不可避免的,垃圾回收算法执⾏一定会出现STW,我们要做的只是减少停顿的时间
GC各种算法优化的重点,就是减少STW(暂停)。 

可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿,进入STW状态

分析工作必须在一个能确保一致性的快照中进行
一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
被STW中断的应用程序线程会在完成GC之后恢复,频繁的中断会让用户感觉卡顿
所以我们要减少STW的发生,也就相当于要想办法降低GC垃圾回收的频率
STW状态和采用哪款GC收集器无关,所有的GC收集器都有这个状态,因为要保证一致性。
但是好的GC收集器可以减少停顿的时间
减少STW(暂停)和降低GC垃圾回收的频率是调优的重点

分代收集算法

在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。

年轻代(Young Gen)年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。

这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。

老年代(Tenured Gen)老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。

这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记清除或者是标记整理的混合实现。

标记阶段的开销与存活对象的数量成正比。

清除阶段的开销与所管理区域的大小成正相关。

压缩阶段的开销与存活对象的数据成正比。

        以HotSpot中的CMS回收器为例,CMS是基于标记清除实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于标记压缩算法的Serialold回收器作为补偿措施:当内存回收不佳(碎片导致的执行失败时),将采用Serial 0ld执行Full GC(标记整理算法)以达到对老年代内存的整理。分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

裁道友不裁贫道

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值