文章目录
JVM思维导图
一、JVM的定义
- JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
- 引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
二、类加载子系统
2.1图示
包含三个部分:加载—>链接—>初始化
2.2作用
- 类加载子系统负责从文件系统或者网络中加载Class文件(Class文件在开头有特定标识)。
- 类加载器(Class Loader)只负责class文件的加载,至于是否可以运行,由执行引擎(Execution Engine)决定。
- 加载的类信息存放于一块成为方法区的内存空间。除了类信息之外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
2.3类加载过程
2.3.1加载过程
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。
2.3.2链接过程
验证(Verify)
目的:在于确保Class文件的字节流中包含信息符合当前JVM规范要求,保证被加载类的正确性,不会危害虚拟机自身安全。
主要包括四种验证:
文件格式验证、源数据验证、字节码验证、符号引用验证
这里就不展开讲了相关信息可以自行 百度
准备(Prepare)
- 为类变量(static)分配内存并且设置初始值。
- 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;
- 不会为实例变量分配初始化,类变量会分配在方法去中,而实例变量是会随着对象一起分配到java堆中。
解析(Resolve)
将常量池内的符号引用转换为直接引用的过程。
事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行
2.3.3初始化
初始化阶段就是执行类构造器方法clInit()的过程。 clInit是ClassInit缩写。此方法并不是程序员定义的构造方法。是javac编译器自动收集类中的所有类变量(Static)的赋值动作和静态代码块中的语句合并而来。构造器方法中指令按语句在源文件中出现的顺序执行若该类具有父类,jvm会保证子类的clinit()执行前,父类的clinit()已经执行完毕
2.4类加载器的分类
- 启动类加载器
负责加载JAVA_HOME/lib目录下的可以被虚拟机识别(通过文件名称,比如rt.jar``tools.jar)的字节码文件。与之对应的是java.lang.ClassLoader类 - 扩展类加载器
负责加载JAVA_HOME/lib/ext目录下的的字节码文件。对应sun.misc.Launcher类 此类继承于启动类加载器ClassLoader - 应用程序类加载器
负责加载ClassPath路径下的字节码 也就是用户自己写的类。对应于sun.misc.Launcher.AppClassLoader类 此类继承于扩展类加载器Launcher - 用户自定义加载器
需要继承系统类加载器ClassLoader,并重写findClass方法。负责加载指定位置的字节码文件。通过类中的path变量指定。
2.5双亲委派机制
原理:
如果一个类加载器收到了类加载的请求,它并不会自己加载,而是先把请求委托给父类的加载器执行
如果父类加载器还有父类,则进一步向上委托,依次递归,请求到达最顶层的引导类加载器。
如果顶层类的加载器加载成功,则成功返回。如果失败,则子加载器会尝试加载。直到加载成功。
双亲委派机制优势
-
避免类的重复加载
当自己程序中定义了一个和Java.lang包同名的类,此时,由于使用的是双亲委派机制,会由启动类加载器去加载JAVA_HOME/lib中的类,而不是加载用户自定义的类。此时,程序可以正常编译,但是自己定义的类无法被加载运行。 -
保护程序安全,防止核心API被随意篡改
双亲委派模型的破坏者-线程上下文类加载器
在Java应用中存在着很多服务提供者接口(Service Provider Interface,SPI),这些接口允许第三方为它们提供实现,如常见的 SPI 有 JDBC、JNDI等,这些 SPI 的接口属于 Java 核心库,一般存在rt.jar包中,由Bootstrap类加载器加载,而 SPI 的第三方实现代码则是作为Java应用所依赖的 jar 包被存放在classpath路径下,由于SPI接口中的代码经常需要加载具体的第三方实现类并调用其相关方法,但SPI的核心接口类是由引导类加载器来加载的,而Bootstrap类加载器无法直接加载SPI的实现类,同时由于双亲委派模式的存在,Bootstrap类加载器也无法反向委托AppClassLoader加载器SPI的实现类。在这种情况下,我们就需要一种特殊的类加载器来加载第三方的类库,而线程上下文类加载器就是很好的选择。
线程上下文类加载器(contextClassLoader)是从 JDK 1.2 开始引入的,我们可以通过java.lang.Thread类中的getContextClassLoader()和 setContextClassLoader(ClassLoader cl)方法来获取和设置线程的上下文类加载器。如果没有手动设置上下文类加载器,线程将继承其父线程的上下文类加载器,初始线程的上下文类加载器是系统类加载器(AppClassLoader),在线程中运行的代码可以通过此类加载器来加载类和资源,如下图所示,以jdbc.jar加载为例
从图可知rt.jar核心包是有Bootstrap类加载器加载的,其内包含SPI核心接口类,由于SPI中的类经常需要调用外部实现类的方法,而jdbc.jar包含外部实现类(jdbc.jar存在于classpath路径)无法通过Bootstrap类加载器加载,因此只能委派线程上下文类加载器把jdbc.jar中的实现类加载到内存以便SPI相关类使用。显然这种线程上下文类加载器的加载方式破坏了“双亲委派模型”,它在执行过程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器,当然这也使得Java类加载器变得更加灵活。
2.6沙箱安全机制
Java安全模型的核心就是Java沙箱(sandbox),什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问,那系统资源包括什么?——CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。
所有的Java程序运行都可以指定沙箱,可以定制安全策略。
组成沙箱的基本组件
- 字节码校验器(bytecode verifier):确保Java类文件遵循Java语言规范。这样可以帮助Java程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类。
- 类装载器(class loader):其中类装载器在3个方面对Java沙箱起作用
- 它防止恶意代码去干涉善意的代码;
- 它守护了被信任的类库边界;
- 它将代码归入保护域,确定了代码可以进行哪些操作。
三、运行时数据区
3.1内部结构
一个进程对应一个jvm实例,一个运行时数据区,又包含多个线程,这些线程共享了方法区和堆,每个线程包含了程序计数器、本地方法栈和虚拟机栈。
注: 方法区对应JDK8之后的元数据区
3.2程序计数器
3.2.1作用
PC寄存器是用来存储指向下一条指令的地址,也就是即将将要执行的指令代码。由执行引擎读取下一条指令。
3.2.2特征
-
它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域
-
在jvm规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致
-
任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined)。
-
它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
-
字节码解释器工作时就是通过改变这个计数器的值来选取下一跳需要执行的字节码指令
-
它是唯一一个在java虚拟机规范中没有规定任何OOM(OutOfMemoryError)情况的区域
1.使用PC寄存器存储字节码指令地址有什么用呢?
- 因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行
- JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令
2.PC寄存器为什么会设定为线程私有?
我们都知道所谓的多线程在一个特定的时间段内指回执行其中某一个线程的方法,CPU会不停滴做任务切换,这样必然会导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。 由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。 这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
3.3栈
3.3.1内存中的堆与栈
- 栈
- 运行时的单位。
- 解决程序的运行问题,即程序如何执行,或者说如何处理数据。
- 存放基本数据类型的局部变量,以及引用数据类型的对象的引用。
- 堆
- 是存储的单位。
- 堆解决的是数据存储的问题,即数据怎么放、放在哪儿。
- 对象主要都是放在堆空间的,是运行时数据区比较大的一块。
3.3.2虚拟机栈是什么
- java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。 每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应这个一次次的java方法调用。它是线程私有的
- 生命周期和线程是一致的
- 作用:主管java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。
- 局部变量:相对于成员变量(或属性)
- 基本数据变量: 相对于引用类型变量(类,数组,接口)
3.3.3栈的特点
- 栈是一种快速有效的分配存储方式,访问速度仅次于PC寄存器(程序计数器)
- JVM直接对java栈的操作只有两个:
- 每个方法执行,伴随着进栈(入栈,压栈)
- 执行结束后的出栈工作
- 对于栈来说不存在垃圾回收问题
3.3.4栈的常见异常
java虚拟机规范允许Java栈的大小是动态的或者是固定不变的
- 如果采用固定大小的Java虚拟机栈,那每一个线程的java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量,java虚拟机将会抛出一个 StackOverFlowError异常
public class StackOverFlowErrorTest {
public static void main(String[] args) {
//递归调用main
main(args);
}
}
递归调用main,当主线程请求分配的栈容量超过java虚拟机栈允许的最大容量,java虚拟机将会抛出一个 StackOverFlowError异常
- 如果java虚拟机栈动态拓展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个 OutOfMemoryError异常
3.3.5设置栈的内存大小
我们可以使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。 (IDEA设置方法:Run-EditConfigurations-VM options 填入指定栈的大小-Xss256k)
/**
* 演示设置栈的内存大小
* 默认情况下:1M count 11404
* 栈大小: -Xss256k count 2466
* 栈大小: -Xss128m count 1656372
*/
public class StackErrorTest {
private static int count = 1;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args);
}
}
3.3.6栈的运行原理
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
- 在这个线程上正在执行的每个方法都对应各自的一个栈帧
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
- JVM直接对java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循先进后出/后进先出的和原则。
- 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method)
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前栈帧。
- 不同线程中所包含的栈帧是不允许相互引用的,即不可能在另一个栈帧中引用另外一个线程的栈帧
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
- Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
3.3.7栈帧内部结构
每个栈帧中存储着:
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)(或表达式栈)
- 动态链接(Dynamic Linking)(或执行运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
- 一些附加信息
局部变量表(Local Variables)
1.概述
- 局部变量表也被称之为局部变量数组或本地变量表
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型
- 由于局部变量表是建立在线程的栈上,是线程私有的数据,因此不存在数据安全问题
- 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的【字节码最大长度】数据项中。在方法运行期间是不会改变局部变量表的大小的。
- 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,他的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
- 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
2.静态变量与局部变量的对比及小结
变量的分类:
- 按照数据类型分:
- ①基本数据类型;
- ②引用数据类型;
- 按照在类中声明的位置分:
- ①成员变量:在使用前,都经历过默认初始化赋值
- static修饰:类变量:类加载的链接的准备阶段给类变量默认赋值——>初始化阶段给类变量显式赋值即静态代码块赋值;
- 不被static修饰:实例变量:随着对象的创建,会在堆空间分配实例变量空间,并进行默认赋值
- ②局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过
- ①成员变量:在使用前,都经历过默认初始化赋值
- 补充:
在栈帧中,与性能调优关系最为密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
操作数栈
1.概念
- 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
- 操作数栈就是jvm执行引擎的一个工作区,当一个方法开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
- 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译器就定义好了,保存在方法的code属性中,为max_stack的值。
- 栈中的任何一个元素都是可以任意的java数据类型
- 32bit的类型占用一个栈单位深度
- 64bit的类型占用两个栈深度单位
- 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标砖的入栈push和出栈pop操作来完成一次数据访问
- **如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,**并更新PC寄存器中下一条需要执行的字节码指令。
- 操作数栈中的元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类验证阶段的数据流分析阶段要再次验证。
2.栈顶缓存技术
-
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数
-
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率
动态链接
-
每一个栈帧内部都包含一个指向运行时常量池或该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如invokedynamic指令
-
在Java源文件被编译成字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Refenrence)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
方法返回地址
- 存放调用该方法的PC寄存器的值。
- 一个方法的结束,有两种方式:
- 正常执行完成
- 出现未处理的异常,非正常退出
- 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出时,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
- 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值也如调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
- 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
当一个方法开始执行后,只要两种方式可以退出这个方法:
1、执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;
- 一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定
- 在字节码指令中,返回指令包含ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用
2、在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜素到匹配的异常处理器,就会导致方法退出,简称异常完成出口方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
附加信息
栈帧中还允许携带与java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。(很多资料都忽略了附加信息)
3.4本地方法接口
1.图示
2.定义
简单来讲,一个Native Method就是一个java调用非java代码的接口,一个Native Method 是这样一个java方法:该方法的实现由非Java语言实现,比如C。这个特征并非java特有,很多其他的编程语言都有这一机制,比如在C++ 中,你可以用extern “C” 告知C++ 编译器去调用一个C的函数。 在定义一个native method时,并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java语言在外面实现的。 本地接口的作用是融合不同的编程语言为java所用,它的初衷是融合C/C++程序。 标识符native可以与其他所有的java标识符连用,但是abstract除外。
3.为什么要使用Native Method
Native使用起来非常方便,然而有些层次的任务用java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。
-
与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调用。
3.5本地方法栈
1.本地方法栈在运行时数据区中的位置
2.定义
- Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用
- 本地方法栈,也是线程私有的。
- 允许被实现成固定或者是可动态拓展的内存大小。(在内存溢出方面是相同的)
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverFlowError异常。
- 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么java虚拟机将会抛出一个OutOfMemoryError异常。
- 本地方法是使用C语言实现的
- 它的具体做法是在虚拟机栈中登记native方法,在Execution Engine执行时加载本地方法库。
- 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限
- 本地方法可以通过本地方法接口来 访问虚拟机内部的运行时数据区
- 它甚至可以直接使用本地处理器中的寄存器
- 直接从本地内存的堆中分配任意数量的内存
- 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。
- 在hotSpot JVM中,直接将本地方法栈和虚拟机栈合二为一。
3.6堆
1.堆的核心概述
一个进程对应一个jvm实例,同时包含多个线程,这些线程共享方法区和堆,每个线程独有程序计数器、本地方法栈和虚拟机栈。
-
一个jvm实例只存在一个堆内存,堆也是java内存管理的核心区域
-
Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间(堆内存的大小是可以调节的)
-
《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
-
所有的线程共享java堆,在这里还可以划分线程私有的缓冲区(TLAB:Thread Local Allocation Buffer).(面试问题:堆空间一定是所有线程共享的么?不是,TLAB线程在堆中独有的)
-
《Java虚拟机规范》中对java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。
-
从实际使用的角度看,“几乎”所有的对象的实例都在这里分配内存 (‘几乎’是因为可能存储在栈上)
-
数组或对象永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置
-
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
-
堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域
2.查看堆内存
// -Xms100m : 设置初始内存100MB
// -Xmx100m : 设置JVM最大内存100MB
// -XX:+PrintGCDetails : 打印GC回收信息
System.out.println("最大可用内存:"+Runtime.getRuntime().maxMemory()/1024/1024+"MB");
System.out.println("当前JVM空闲内存:"+Runtime.getRuntime().freeMemory()/1024/1024+"MB");
System.out.println("当前JVM占用的内存总数:"+Runtime.getRuntime().totalMemory()/1024/1024+"MB");
3.堆的细分内存结构
-
JDK7及以前
- 逻辑:新生区(伊甸园区+幸存者1区+幸存者2区)+养老区+永久区(方法区在1.7的实现)
- 逻辑:新生区(伊甸园区+幸存者1区+幸存者2区)+养老区+永久区(方法区在1.7的实现)
-
JDK8及以后
- 逻辑:新生区(伊甸园区+幸存者1区+幸存者2区)+养老区+元空间(直接内存)(方法区在1.8的实现)
4.新生代和老年代
-
存储在JVM中的java对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速(存入新生代)
- 另外一类对象时生命周期非常长,在某些情况下还能与JVM的生命周期保持一致 (存入老年代)
-
Java堆区进一步细分可以分为新生代(YoungGen)和老年代(OldGen)
- 其中新生代可以分为伊甸园区(Eden)、新生区1(from)和新生区2(to)
5.图解对象分配过程
为新对象分配内存是件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配的问题,并且由于内存分配算法与内存回收算法密切相关, 所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
(1)new的对象先放伊甸园区。此区有大小限制。
(2)当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。将伊甸园中的剩余对象移动到幸存者0区。
(3)然后加载新的对象放到伊甸园区
(4)如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
(5)如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
(6)啥时候能去养老区呢?可以设置次数。默认是15次。·可以设置参数:
-XX:MaxTenuringThreshold=进行设置。
(7)在老年区,相对悠闲。当老年区内存不足时,再次触发GC:Major GC,进行养老区的内存清理。
(8)若老年区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常
总结:
针对幸存者s0,s1区:复制之后有交换,谁空谁是to
关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不再永久区/元空间收集。
6.MinorGC 、MajorGC、Full GC
JVM在进行GC时,并非每次都针对上面三个内存区域(新生代、老年代、方法区)一起回收的,大部分时候回收都是指新生代。
针对hotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)
-
整堆收集(Full GC):收集整个java堆和方法区的垃圾收集
-
部分收集
- 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
- 目前,只有CMS GC会有单独收集老年代的行为
- 注意,很多时候Major GC 会和 Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
7.堆空间分代思想
为什么要把Java堆分代?不分代就不能正常工作了么
- 经研究,不同对象的生命周期不同。70%-99%的对象都是临时对象。
新生代:有Eden、Survivor构成(s0,s1 又称为from to),to总为空
老年代:存放新生代中经历多次依然存活的对象 - 其实不分代完全可以,分代的唯一理由就是优化GC性能。 如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。 GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描,而很多对象都是朝生夕死的。 如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
8.为对象分配内存:TLAB
8.1为什么要有TLAB?
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
8.2什么是TLAB - 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内
-
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
-
所有OpenJDK衍生出来的JVM都提供了TLAB的设计
9.TLAB对象分配过程
3.7方法区
3.7.1栈,堆,方法区的交互关系
1.运行时数据区结构图
2.堆、栈、方法区的交互关系
3.方法区在jdk7及jdk8的落地实现
方法区是一种规范
- JDK1.7及之前,用永久代实现,使用虚拟机的内存
- JDK1.8及以后,用元数据区实现,使用本地内存
3.7.2方法区的理解
-
《Java虚拟机规范》中明确说明:‘尽管所有的方法区在逻辑上属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。’但对于HotSpotJVM而言,方法区还有一个别名叫做Non-heap(非堆),目的就是要和堆分开。所以,方法区可以看作是一块独立于Java堆的内存空间
-
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域
-
方法区在JVM启动时就会被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的
-
方法区的大小,跟堆空间一样,可以选择固定大小或者可拓展
-
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:OOM。
-
比如:
- 加载大量的第三方jar包;
- Tomcat部署的工程过多;
- 大量动态生成反射类;
- 关闭JVM就会释放这个区域的内存
3.7.3方法区的内部结构
1.方法区在运行时数据区中的位置
2、方法区存储的信息
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
3.7.4运行时常量池
-
几种在常量池内存储的数据类型包括:
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
-
运行时常量池( Runtime Constant Pool)是方法区的一部分。
-
常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
-
运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
-
JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
-
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
-
运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。
-
运行时常量池类似于传统编程语言中的符号表(symbol table) ,但是它所包含的数据却比符号表要更加丰富一些。
-
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。
3.7.5方法区的演进细节
-
首先明确:只有HotSpot才有永久代。 BEA JRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虛拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。
-
针对HotSpot
版本 | 方法区实现 |
---|---|
jdk1.6及之前 | 静态变量及字符串常量池存放在永久代(方法区1.6的实现)上 |
jdk1.7 | 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中 |
jdk1.8及之后 | 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆 |
3.7.8永久代为什么要被元空间替换
-
随着Java8的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类.的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间( Metaspace )。
-
由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。
-
这项改动是很有必要的,原因有:
- (1)为永久代设置空间大小是很难确定的。
- 在某些场景下,如果动态加载类过多,容易产生Perm区的O0M。
- 比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。 “Exception in thread’ dubbo client x.x connector’java.lang.OutOfMemoryError: PermGenspace”
- 而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
- (2)对永久代进行调优是很困难的
- (1)为永久代设置空间大小是很难确定的。
StringTable 为什么要调整
jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full GC 是老年代的空间不足、永久代不足时才会触发。这就导致了StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
四、执行引擎
4.1执行引擎概述
-
执行引擎是Java虚拟机的核心组成部分之一。
-
JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表和其他辅助信息
-
那么,如果想让一个Java程序运行起来、执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者.
4.2Java代码编译执行过程
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下面图中的各个步骤
- 解释型语言走中间一行
- 编译型语言走下边一行
Java代码编译是由Java源码编译器来完成的
Java字节码的执行是由JVM执行引擎来完成的
解释器
当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
JIT编译器
就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。
4.3为什么说Java是半编译半解释型语言?
Java不同于一般的编译语言和直译语言。它首先将源代码编译成字节码, 然后依赖各种不同平台上的虚拟机来解释执行字节码,从而实现了“一次编写,到处运行”的跨平台特性, 所以说java是一种半编译半解释型的语言。
记住虽然Java程序在运行之前也有一个编译过程,但是并不是将程序编译成机器语言,而是将它编译成字节码(可以理解为一个中间语言
五、垃圾回收机制
5.1垃圾定义
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。 如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空 间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
5.2为什么需要GC
-
对于高级语言来说,一个基本认知是,如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。
-
除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM 将整理出的内存分配给新的对象。
-
随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。
5.3垃圾回收相关算法
5.3.1对象存活判断
在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
- 判断对象存活一般有两种方式:引用计数算法和可达性分析算法
5.3.1.1引用计数算法
1.1 概念
-
引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型 的引用计数器属性。用于记录对象被引用的情况。
-
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
1.2 优点
- 实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
1.3 缺点
-
需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
-
每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
-
引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一 条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法
5.3.1.2可达性分析算法
-
相对于引用计数而言,可达性分析算法解决了循环引用的问题。防止了内存泄露的发生。
-
基本思路
- 可达性分析算法是以根对象(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
-
使用可达性分析算法之后,内存中存活的对象都会被根对象集合直接或者间接连接,搜索走过的路径叫做引用链。
-
如果目标对象没有任何引用链相连,则表示不可达,为垃圾。
5.3.2对象的finalization机制
- Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
- 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。
- finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
- 永远不要主动调用某个对象的finalize ()方法,应该交给垃圾回收机制调用。理由包括下面三点:
- ➢在finalize()时可能会导致对象复活。
- ➢finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
- ➢一个糟糕的finalize ()会严重影响GC的性能。
- 从功能上来说,finalize()方法与C++ 中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质,上不同于C++ 中的析构函数。
5.3.2.1对象是否"死亡
- 由于finalize ()方法的存在,虚拟机中的对象一般处于三种可能的状态。
- 如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:
- ➢可触及的:从根节点开始,可以到达这个对象。
- ➢可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
- ➢不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize() 只会被调用一一次。
- 以上3种状态中,是由于finalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。
5.3.2.2.判定是否可以回收具体过程
-
如果对象objA到GC Roots没有引用链,则进行第一 次标记。
-
进行筛选,判断此对象是否有必要执行finalize()方法
①如果对 象objA没有重写finalize()方法,或者finalize ()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。
②如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F一Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。
③finalize()方法是对象逃脱死亡的最后机会,稍后Gc会对F一Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。 在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。
5.4清除阶段
当成功区分出内存中存活对象和死亡对象之后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的空间。目前比较常用的算法有三种:
标记清除算法复制算法标记
压缩算法
5.4.1标记清除算法
- 背景
- 标记清除算法是一种非常基础和常见的垃圾收集算法
- 执行过程
- 当堆中的有效内存空间被耗尽时,就会停止程序STW,然后进行标记-清除
- 标记:Collector从引用的根节点开始遍历,标记所有的被引用的对象,在对象的对象头中记录为可达对象
- 清除:将对象头中没有标记为可达对象的对象进行清除
- 优点:
- 常用,简单
- 缺点
- ➢效率不算高(两次O(n))
- ➢在进行GC的时候,需要停止整个应用程序,导致用户体验差
- ➢这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表
- 何为清除?
- 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲
的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。
- 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲
5.4.2复制算法
-
背景
为了解决标记清除算法效率方面的问题,M.L.Minsky于1963年发表了著名的论文,“ 使用双存储区的Li sp语言垃圾收集器CALISP Garbage Collector Algorithm Using SerialSecondary Storage )”。M.L. Minsky在该论文中描述的算法被人们称为复制(Copying)算法,它也被M. L.Minsky本人成功地引入到了Lisp语言的一个实现版本中。 -
核心思想
将活着的内存空间分为两块,每次使用一块,进行垃圾回收的时候,将存活对象复制到另一块未使用的区域,然后将源区域清空,然后交换两个内存的角色
- 优点:
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间连续性,不会出现“碎片”问题。
- 缺点:
-
此算法的缺点也是很明显的,就是需要两倍的内存空间。
-
对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
-
特别的如果系统中的可用对象很多,复制算法不会很理想,因为要复制大量的对象
-
在新生代,对常规应用的垃圾回收,一次通常可以回收70%一 99%的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
5.4.3标记压缩算法
-
背景
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。 标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。标记一压缩(Mark一Compact) 算法由此诞生。 1970年前后,G. L. Steele 、C. J. Chene和D.S. Wise 等研究者发布标记一压缩算法。在许多现代的垃圾收集器中,人们都使用了标记一压缩算法或其改进版本。 -
核心思想
- 第一阶段和标记一清除算法一样,从根节点开始标记所有被引用对象.
- 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
- 之后,清理边界外所有的空间。
图示
-
标记一压缩算法的最终效果等同于标记一清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记一清除一压缩(Mark一 Sweep一Compact)算法。
-
二者的本质差异在于标记清除算法是一种非移动式的回收算法,标记压.缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
-
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
5.4.4对比三种算法
属性\算法 | 标记清除算法 | 复制算法 | 标记压缩算法 |
---|---|---|---|
时间复杂度 | 中 | 快 | 满 |
空间复杂度 | 少 | 占用2倍 | 少 |
内存碎片 | 有 | 无 | 无 |
移动对象 | 否 | 是 | 是 |
5.4.5分代收集算法
前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。
分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。
一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,
比如Http请求中的Session对象、线程、Socket连接, 这类对象跟业务直接挂钩,因此生命周期比较长。
但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如: String对象, 由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
目前几乎所有的GC都是采用分代收集(Generational Collecting) 算法执行垃圾回收的。
在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。
- 年轻代(Young Gen)
- 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
- 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
- 老年代(Tenured Gen)
- 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
- 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记清除或者是标记整理的混合实现。
- ➢标记阶段的开销与存活对象的数量成正比。
- ➢清除阶段的开销与所管理区域的大小成正相关。
- ➢压缩阶段的开销与存活对象的数据成正比。
以HotSpot中的CMS回收器为例,CMS是基于标记清除实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于标记压缩算法的Serialold回收器作为补偿措施:当内存回收不佳(碎片导致的执行失败时),将采用Serial 0ld执行Full GC(标记整理算法)以达到对老年代内存的整理。 分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。
5.5内存溢出与内存泄漏
内存溢出
内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。
由于GC一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现OOM的情况。
大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full GC操作,这时候会回收大量的内存,供应用程序继续使用。
javadoc中对OutOfMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
-
首先说没有空闲内存的情况:说明Java虚拟机的堆内存不够,原因有二:
- (1) Java虚拟机的堆内存设置不够。 比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数一Xms、一Xmx来调整。
- (2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)对于老版本的Oracle JDK,因为永久代的大小是有限的,并且JVM对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致OOM问题。对应的异常信息,会标记出来和永久代相关: “java. lang. OutOfMemoryError: PermGen space”。 随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的OOM有所改观,出现OOM,异常信息则变成了:“java. lang. OutOfMemoryError: Metaspace"。 直接内存不足,也会导致OOM。
-
这里面隐含着一层意思是,在抛出OutOfMemoryError之 前,通常垃圾收集器会被触发,尽其所能去清理出空间。
-
➢例如:在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象等。
-
➢在java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System.gc()会被调用,以清理空间。
当然,也不是在任何情况下垃圾收集器都会被触发的 -
➢比如,我们去分配一一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接拋出OutOfMemoryError
-
内存泄漏(Memory Leak)
-
也称作“存储渗漏”。严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。
-
但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致内存溢出OOM,也可以叫做宽泛意义上的“内存泄漏
-
尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现0utOfMemory异常,导致程序崩溃。
-
注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。
-
举例
- 1、单例模式 单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
- 2、一些提供close的资源未关闭导致内存泄漏 数据库连接( dataSourse. getConnection()),网络连接(socket)和io连接必须手动close,否则是不能被回收的。
5.6Java中的引用
项目 | Value | 应用场景 |
---|---|---|
强引用 | 死也不回收 | 大部分 |
软引用 | 内存不足时回收 | 缓存 |
弱引用 | GC即将回收 | 缓存 |
虚引用 | GC时对象跟踪 | |
终结器引用 |
5.6.1强引用(StrongReference)
-
定义
最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。 -
特征
- 在Java程序中,最常见的引用类型是强引用(普通系统99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型。
- 当在Java语言中使用new操作符创建一个新的对象, 并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。
- 强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。
- 对于一一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。
- 相对的,软引用、 弱引用和虚引用的对象是软可触及、弱可触及和虛可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成Java内存泄漏的主要原因之一。
5.6.2软引用(SoftReference)
-
定义
在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。 -
特征
- 软引用是用来描述一 些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
- 软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
- 垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列( Reference Queue)。
- 类似弱引用,只不过Java虚拟机会尽量让软引用的存活时间长一些,迫不得.已才清理。
- 在JDK 1. 2版之后提供了java.lang.ref.SoftReference类来实现软引用。
5.6.3弱引用(WeakReference)
-
定义
被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。 -
特征
- 弱引用也是用来描述那些非必需对象,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。
- 但是,由于垃圾回收器的线程通常优先级很低,因此,并不一 定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。
- 弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。
- 软引用、弱引用都非常适合来保存那些可有可无的缓存数据。
如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。
而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。 - 在JDK1.2版之后提后了java.lang.ref.WeakReference类来实现弱引用
- 弱引用对象与软引用对象的最大不同就在于,当GC在进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC总是进行回收。弱引用对象更容易、更快被GC回收。
5.6.4虚引用(PhantomReference)
-
定义
一个对象是否有虛引用的存在,完全不会对其生存时 间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虛引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知(回收跟踪)。 -
特征
- 虚引用(Phantom Reference),也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个。
- 一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。
- 它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法取得对象时,总是null。
- 为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。
- 虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虛引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。
- 由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虛引用中执行和记录。
- 在JDK 1. 2版之后提供了PhantomReference类来实现虚引用。
5.6.5垃圾回收器
- 1.优先调整堆的大小让JVM自适应完成。
- 2.如果内存小于100M,使用串行收集器
- 3.如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
- 4.如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
- 5.如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器
- 官方推荐G1,性能高。现在互联网的项目,基本都是使用G1。