JVM
知识点
1. JVM
概述
补充:JRE/JDK/JVM
是什么关系
JRE
(JavaRuntimeEnvironment
,Java运行环境),也就是Java平台。所有的Java程序都要在JRE
下才能运行。普通用户只需要运行已开发好的java
程序,安装JRE
即可。
**JDK
(Java Development Kit)**是程序开发者用来编译、调试java
程序用的开发工具包。JDK
的工具也是Java程序,也需要JRE
才能运行。为了保持JDK
的独立性和完整性,在JDK
的安装过程中,JRE
也是安装的一部分。所以,在JDK
的安装目录下有一个名为jre
的目录,用于存放JRE
文件。
**JVM
(Java Virtual Machine,Java虚拟机)**是JRE
的一部分。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM
有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java语言最重要的特点就是跨平台运行,使用JVM
就是为了支持与操作系统无关,实现跨平台。
1.1虚拟机
虚拟机(Virtual Machine)指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统。在实体计算机中能够完成的工作在虚拟机中都能够实现。
所谓虚拟机,就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机。
大名鼎鼎的VMware
就属于系统虚拟机,它是完全对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台。程序虚拟机典型的代表就是Java虚拟机了,它专门为执行某个计算机程序而设计。在Java虚拟机中执行的指令我们称为java
字节码指令。
无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制与虚拟机提供的资源。Java虚拟机是一种执行java
字节码文件的虚拟计算机,它拥有独立的运行机制。
Java技术的核心就是java
虚拟机,因为所有的java
程序都运行在java
虚拟机内部。
1.2JVM
(Java虚拟机)
JVM
是Java Virtual Machine(Java虚拟机)的缩写,JVM
是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,是的Java语言编译程序秩序生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
1.3 JVM
作用
Java虚拟机就是二进制字节码的运行环境,负责装在字节码到其内部,解释/编译为对应平台上的机器密码指令执行,每一条java
指令,java
虚拟机中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪儿。
特点:
一次编译到处运行
自动内存管理
自动垃圾回首功能
现在的JVM
不仅可以执行java
字节码文件,还可以执行其他语言编译后的字节码文件,是一个跨语言平台。
1.4JVM
位置
JVM
试运行在操作系统之上的,它与硬件没有直接的交互。
1.5JVM
整体组成可分为四个部分
1.类加载器(ClassLoader
)
2.运行时数据区(Runtime Data Area)
3.执行引擎(Execution Engine)
4.本地库接口(Native Interface)
简图:
详细图:
1.6各个组成部分的用途
程序在执行之前先要把java
代码转换成字节码(.class文件),jvm
首先把字节码通过一定的方式 类加载器(ClassLoader
) 把文件加载到内存中 运行时数据区(Runtime Data Area),而字节码文件是jvm
的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine) 将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要其他语言的接口 本地库接口(Native Interface) 来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。
1.7 Java代码的执行流程
Java编译期编译过程中,任何一个节点执行是把就会造成编译失败。虽然哥哥平台的java
虚拟机内部实现细节不尽相同,但是它们执行的字节码内容却是一样的。
JVM
主要内容就是负责将字节码装载到其内部,解释/编译为对应平台上的机器指令执行。JVM
使用类加载器(Class Loader)装载class 文件。
类加载完成后,会进行字节码校验,字节码校验通过之后JVM
解释器会把字节码翻译成机器码交由操作系统执行。
但不是所有的代码都是解释执行,JVM
对此作了优化,比如HotSpot
虚拟机,它本身提供了 JIT
(Just In Time)(编译器)
1.8JVM
架构模型
Java编译器输入的指令流基本上是一种基于栈的指令集架构,另一种指令集架构是基于寄存器的指令集架构。
这两种架构之间的区别:
基于栈式架构的特点:
设计和实现更简单,是用于资源受限的系统。
使用零地址指令方式分配,其执行过程依赖于操作栈,指令集更小,编译器容易实现。
不需要硬件支持,可移植性好,更好实现跨平台。
基于寄存器式架构特点:
指令完全依赖于硬件,可移植性差。
性能优秀,执行更高效。
完成一项操作使用的指令更少。
使用 javap -v class
文件可以将class文件反编译为指令集。
所以由于跨平台的设计,Java指令集都是根据栈来设计的,不同CPU架构不同,所以不能设计为基于寄存器的。
优点:跨平台,指令集小,编译器容易实现。
缺点:性能下降,实现同样功能需要更多的指令。
2.JVM
结构-类加载
2.1类加载子系统的作用
把字节码文件(.class文件)加载到内存里,存储在运行时数据区中的方法区。 类加载器子系统负责从文件系统或者网络中加载class文件,class文件在文件开头有特定的文件标识(字节码文件都以CA FE BA BE 标识开头)
classLoader
只负责class文件的加载,至于它是否可以运行,则由Execution Engine (执行引擎)决定。加载的类信息存放于一块称为方法区的内存空间。除了累的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息)是class文件中常量池部分的内存映射)。
2.2类加载器ClassLoader
的角色
负责把字节码文件(class文件)加载并初始化。
- class file 存放在硬盘上,可以理解为一个模板,而最终这个模板在执行的时候是要加载
JVM
当中来,根据这个模板实例化出n个一模一样的实例。 - class file 加载到
JVM
中,被称为 DNA 元数据模板,放在方法区中。 - 在 .class —>
JVM
—>最终成为元数据模板,此过程就要有一个运输工具(类加载器)
2.3 类加载过程
2.3.1 加载
1.通过类名(地址)获取此类的二进制字节流。
2.将这个字节流所代表的的静态存储结构转换为方法区(元空间)的运行时结构。
3.在内存中生成一个代表这个类的 java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
解释:类加载器把文件加载到内存中怎么存储?为每个类创建一个Class对象,来存储字节码中的信息。调用Class类中的方法获取类的信息。
2.3.2 链接
验证:检验被加载的类是否有正确的内部结构,并和其他类协调一致;
准备:准备阶段则负责为类的静态属性分配内存,并设置默认初始值;
不包含用final修饰的static,实例变量,在编译时进行初始化。
不会为实例变量初始化。(访问静态常量,不会导致类加载(常量访问的优化;访问静态变量,加载类)
解析:将类的二进制数据中的符号引用替换成直接引用(符号引用是用一组符号描述所引用的目标;直接引用是指向目标的指针)。
2.3.4 初始化
类什么时候初始化?
1.创建类的实例,也就是new一个对象
2.访问某个类或接口的静态变量,或者对该静态变量赋值
3.调用类的静态方法
4.反射( Class.forName("")
)
5.初始化一个类的子类(会首先初始化子类的父类)
类初始化顺序
对static修饰的变量或语句块进行赋值。
如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。
如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
顺序是:父类static —> 子类static —>父类构造方法 —> 子类构造方法
2.4 类加载器分类
JVM
支持两种类型的类加载器,分别为 引导类加载器 和 自定义类加载器。
从概念上来讲,自定义类加载器一般指的是程序汇总由开发人员自定义的一类加载器。但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader
的类加载器都划分为自定义类加载器。
无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个:
2.4.1 引导类加载器(启动类加载器)
这个类加载器使用C/C++语言实现,嵌套在JVM
内部,它用来加载java
核心类库。
并不集成于 java.lang.ClassLoader
没有父加载器。
负责加载扩展类加载器和应用类加载器,并为它们指定父类加载器。
处于安全考虑,引用类加载器只加载包名为 java,javax,sun
等开头的类。
2.4.2 扩展类加载器
Java语言编写的,由sun.misc.Launcher$ExtClassLoader
实现。
派生于ClassLoader
类。
上层类加载器为引用类加载器。
从java.ext.dirs
系统属性所指定的目录中加载类库,或从JDK
系统安装目录的**jre/lib/ext
**子目录(扩展目录)下加载类库,如果用户创建的jar
放在此目录下,也会自动由扩展类加载器加载。
2.4.3 应用程序类加载器(系统类加载器)
Java语言编写的,由sun.misc.Launcher$AppClassLoader
实现。
派生于ClassLoader
类。
上层类加载器为扩展类加载器。
加载我们自己定义的类。
该类加载器是程序中默认的类加载器。
通过 类名.class.getClassLoader()
,ClassLoader.getSystemClassLoader()
来获得。
ClassLoader
类,它是一个抽象类,其后所有的类加载器都继承自ClassLoader
(不包括启动类加载器)
2.5 双亲委派机制
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要该类时才会将它的class文件加载到内存中生成class对象,而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。(先从最高层找,最高层没有在往下找)
工具原理:
1.如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父级的类加载器去执行。
2.如果父类加载器还存在父类加载器,则进一步向上委托,依次递归,请求最终到达顶层的启动类加载器。
3.如果父类加载器可以完成类的加载任务,就成功返回,倘若父类加载器无法完成加载任务,子加载器才会尝试自己去加载,这就是双亲委派机制。
如果均加载失败,就会抛出 ClassNotFoundException
异常。
双亲委派优点:
1.安全,可避免用户自己编写的类动态替换Java的核心类。如 java.lang.String
2.避免全限定命名的类重复加载(使用了 findLoadClass
()判断当前类是否已加载)
2.6沙箱安全机制
作用:防止恶意代码污染java
源代码
一个例子:
我们自己定义一个类名为String,所在包也命名为java.lang
,因为这个类本来是属于jdk
的,如果没有沙箱安全机制的话,这个类将会污染到系统中的String,但是由于沙箱安全机制,所以就委托顶层的引导类加载器查找这个类,如果没有的话就委托给扩展类加载器,在没有就委托给系统类加载器。但是由于String就是jdk
的源代码,所以在引导类加载器那里就加载到了,先找到先使用,所以就使用引导类加载器里面的String,后面的一概不能使用,这就保证了不被恶意代码污染。
本质上还是双亲委派机制。
2.7 类的主动使用/被动使用
JVM
规定,每个类或者接口被首次主动使用时才对其进行初始化,有主动只用,自然就有被动使用。
主动使用:
通过new关键字导致类的初始化,这是大家经常使用的初始化一个类的方式,它肯定会导致类的加载并且初始化。
访问类的静态变量,包括读取和更新。
访问类的静态方法。
对某个类进行反射操作,会导致类的初始化。
初始化子类会导致父类的初始化。
执行该类的main函数。
被动使用:
其实除了上面的几种主动使用其余就是被动使用了
1.引用该类的静态常量,注意是常量,不会导致初始化,但是也有意外,这里的常量是指已经制定字面量的常量,对于那些需要一些计算才能得出的常量就会导致初始化。
比如:
public final static int NUMBER = 5 ; //不会导致类初始化,被动使用
public final static int RANDOM = new Random().nextInt(); //会导致类的初始化,主动使用
2.构造某个类的数组时不会导致该类的初始化。比如:
Student[] students = new Student[10] ;
主动使用和被动使用的区别在于类是否会被初始化。
面试题
在jvm
中如何判断两个对象是属于同一个类?
1.类的全类名(地址)完全一致。
2.类的加载器必须相同
小结
jvm
提供不同的类加载器,来加载不同的类。
引导类加载器 加载核心类
扩展类加载器
应用程序/系统类加载器 加载我们自己定义的类
双亲委派机制
当加载一个类时,会先一级一级向上交给父级类加载器,先找系统级别的类(原因:怕自己写的类把系统的类替换掉),如果父类加载器没有找到,就会让子级加载器查找。
3.Java
运行时数据区
3.1 运行时数据区组成概述
JVM
的运行时数据区,不同虚拟机实现可能略微有所不同,但都会遵从Java虚拟机规范,Java 8 虚拟机规范规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:
3.1.1 程序计数器(Program counter Register)
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。(用来记录当前线程执行的代码执行到哪一行)
3.1.2 Java虚拟机栈(Java Virtual Machine Stacks)
描述的是Java方法执行的内存模型,每个方法再执行的同时都会创建一个线帧(Stack Frame)用来存储局部变量表、操作数栈、动态链接、方法出口等信息,每个方法从调用直至执行完成的过程,都对应着一个线帧在虚拟机栈中入栈到出栈的过程。
3.1.3 本地方法栈(Native Method Stack)
与虚拟机栈的作用是一样的,只不过虚拟机栈是服务Java方法的,而本地方法栈是为虚拟机调用Native方法(本地方法)服务的。
3.1.4 Java堆(Java Heap)
是Java虚拟机中内存最大的一块,是被所有线程共享的,在你虚拟机启动时候创建,Java堆唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存,随着 JIT
编译器 的发展和逃逸分析技术的主键成熟,栈上分配、标量替换优化的技术将会导致一些微妙的变化,所有的对象都分配在堆上渐渐变得不那么“绝对”了。
3.1.5 方法区(Method Area)
用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。
内存区域是很中要的系统资源,是硬盘和CPU的中间桥梁,承载着操作系统和应用程序的实时运行,JVM
内存布局规定了Java在运行过程中内存申请,分配,管理的策略,保证了JVM
的高效稳定运行,不同的JVM
对于内存的划分方式和管理机制存在着部分差异,我们现在以使用最为流行的HopSpot
虚拟机为例。
Java虚拟机定义了若干种程序运行期间会使用到的运行数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的。这些与线程对应的区域会随着线程开始和结束而创建销毁。
如下:红色的为多个线程共享,灰色的为单个线程私有的,即
线程间共享:堆,堆外内存(计算机内存)。
每个线程:独立包括程序计数器,栈,本地方法栈。
3.2程序计数器
3.2.1概述
可以理解为物理上的PC计数器(或指令计数器,也成为程序钩子)。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。
3.2.2作用
程序计数器用来存储下一条指令的地址,也即将要执行的指令代码,由执行引擎读取下一条指令。
它是一块很小的内存空间,几乎可以忽略不计,也是运行速度最快的存储区域。
在JVM
规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程生命周期保持一致。
任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM
指令地址。如果是在执行native方法(本地方法),则是未指定值(undefined)。
它是程序控制流的指示器,分支,循环,跳转,异常处理,线程回复等基础功能都需要依赖这个计数器来完成。
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError
情况(内存不够)的区域。
面试题
1.使用程序计数器存储字节码指令地址有什么用?为什么使用程序计数器记录当前线程的执行地址呢?
因为CPU需要不停的切换各个线程,这时候切换回来后 ,就得知道接着从哪儿开始继续执行。
JVM
的字节码解释器就需要通过改变程序计数器的值来明确下一条应该执行什么样的字节码指令。
2.程序计数器为什么被设定为线程私有的?
所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?
为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个程序计数器,这样一来各个线程之间便可以独立计算,从而互不干扰。
3.3 Java虚拟机栈(Java Virtual Machine Stacks)
3.3.1虚拟机栈出现的背景
前面已经讲过,由于跨平台性的设计Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。
基于栈的指令设计优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样功能需要更多的指令集。
3.3.2 分清栈和堆
栈是运行时的单位,而堆是存储的单位。
即:栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。
堆解决的是数据存储的问题。即数据怎么放,放在哪儿。
3.3.3 Java虚拟机栈是什么?
Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个栈帧,对应着一次方法的调用。
Java虚拟机栈是线程私有的。
生命周期和线程一致。
3.3.4 作用
主管Java程序的运行,它保存方法的局部变量(8种基本数据类型,对象的引用地址),部分结果,并参与方法的调用和返回。
3.3.5 栈的特点
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
JVM
直接对Java栈的操作只有两个:
调用方法,进栈
执行结束后,出栈
对于栈来说不存在垃圾回收问题。
3.3.6 栈中出现的异常
StackOverFlowError
:线程请求的栈深度大于虚拟机所允许的深度。(栈内存溢出)
OutOfMemoryError
:如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。(内存不够用)
3.3.7 栈中存储什么?
局部变量;调用某个方法的位置/方法地址,根据地址找到方法。
每个线程都有自己的栈,栈中的数据都以栈帧为单位存储。
在这个线程上正在执行的每个方法都各自对应一个线帧。
线帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
3.3.8 栈的运行原理
JVM
直接对java
栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出/后进先出”的原则。
在一条活动的线程上,一个时间点上,只会有一个活动栈。即只有当前在执行的方法的栈帧(栈顶)是有效的,这个栈帧被称为当前栈,与当前栈帧对应的方法称为当前方法,定义这个方法的类称为当前类。
执行引擎运行的所有字节码指令只针对当前线帧进行操作。
如果在该方法中调用了其他方法,对应的新的栈帧就会被创建出来,放在栈的顶端,成为新的当前栈帧。
不同线程中所包含的栈帧(方法)是不允许存在相互引用的,即不可能在一个栈中引用另一个线程的栈帧(方法)。
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前线帧,使得前一个栈帧重新成为当前栈帧。Java方法有两种返回的方式,一种是正常的函数返回,使用return指令,另一种是抛出异常。不管哪种方式,都会导致栈帧被弹出。
3.3.9 栈帧的内部结构
每个栈帧中存储着:
局部变量表(Local Variables)
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。
操作数栈(Operand Stack)(或表达式栈)
栈最典型的一个应用就是用来对表达式求值。在一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。
动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。
方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。
一些附加信息
面试题
什么情况下会出现栈溢出(StackOverFlowError
)
栈溢出就是方法执行是创建的栈帧超过了栈的深度。那么最有可能的就是方法递归调用产生这种结果。
通过调整栈大小,就能保证不出现溢出吗?
不能。方法递归没有出口。
分配的占内存越大越好吗?
不是,只能延缓这种现象的出现,可能会影响其他内存空间。
垃圾回收机制是否会涉及到虚拟机栈?
不会。
3.4 本地方法栈(Native Method Stack)
Java虚拟机栈管理java
方法的调用,而本地方法栈用于管理本地方法的调用。
本地方法栈是线程私有的。
允许被实现成固定或者是可动态扩展的内存大小,内存溢出方面也是相同的。
如果线程请求分配的栈容量超过本地方法栈允许的最大容量抛出StackOverFlowError
(栈溢出)。
如果本地方法可以动态扩展,并在扩展时无法申请到足够的内存会抛出outOfMemoryError
(内存溢出)。
本地方法是用C语言写的。
它的具体做法是在Native Method Stack
(本地方法栈)中登记 native 方法,在Excution Engine
执行时加载本地方法库。
3.5 Java堆内存
3.5.1 堆内存概述
1.一个JVM
实例只存在一个堆内存,堆也是Java内存管理的核心区域。
2 .Java堆区在JVM
启动时的时候即被创建,其空间大小也就确定了,是JVM
管理的最大一块内存空间。
堆内存的大小是可以调节。
例如:-Xms10m
(堆起始大小) -Xmx30m
(堆最大内存大小)
一般情况可以将起始值和最大值设置为一致,这样会减少垃圾回收之后堆内存重新分配大小的次数,提升效率。
3.《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但逻辑上它应该被视为连续的。
4.所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区。
5.《Java虚拟机规范》中对Java堆的描述是:所有的对象实例都应当在运行时分配在堆上。
6.在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
7.堆,是GC
(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
3.5.2堆内存区域的划分
Java 8及之后堆内存分为:新生区(新生代)+ 老年区(老年代)
新生区分为 Eden(伊甸园)区和Survivor(幸存者)区
3.5.3 为什么分区(代)?
将对象根据存活概率进行分类,对存活时间长的对象,放到固定区(老年代),从而减少扫描垃圾及GC
频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。
3.5.4 对象创建内存分配过程
为新对象分配内存是一件非常严谨和复杂的任务,JVM
的设计者们不仅需要考虑内存如何分配,在哪分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC
执行完内存回收后是否会在内存空间中产生内存碎片。
1.new
的新对象先放到伊甸园区,此区大小有限制。
2.当伊甸园的空间填满时,程序又需要创建对象,JVM
的垃圾回收机制将对伊甸园区进行垃圾回收(Minor GC
),将伊甸园区中的不再被其他对象所引用的对象进行销毁,再加载新的对象放到伊甸园区。
3.然后将伊甸园区中的剩余对象移动到幸存者0区。
4.如果再次进行垃圾回收,此时上次幸存下来的存放到幸存者0区的对象,如果没有回收,就会被放到幸存者1区,每次会保证有一个幸存者区是空的。
5.如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。(就是两个区来回倒)
6.什么时候去养老区呢?默认是15次,也可以设置参数(即经历15次垃圾回收) -XX:MaxTenuringThreshold=<N>
7.在老年区,相对悠闲,当养老区内存不足时,再次出发GC
:Major GC
,进行养老区的内存清理。
8.若养老区执行了Major Gc
之后发现依然无法进行对象保存,就会产生OOM
异常。java.lang.OutOfMemoryError:Java heap space
一个会产生OOM
异常的例子:
public static void main(String[] args) {
List<Integer> list = new ArrayList();
while(true){
list.add(new Random().nextInt());
}
}
3.5.5新生区与老年区配置比例
配置新生代与老年代在堆结构的占比(一般不会调)
1.默认-XX:NewRatio=2
,表示新生代占1,老年代占2,新生代占整个堆的1/3。
2.可以修改-XX:NewRatio=4
,表示新生代占1,老年代占4,新生代占整个堆的1/5。
3.当发现在整个项目中,生命周期长的对象偏多,那么就可以通过调整老年代的大小,来进行调优。
在HotSpot
中,Eden空间和另外两个survivor空间缺省所占的比例是8:1:1
,当然开发人员可以通过选项-XX:SurvivorRatio
调整这个空间比例。
新生代的对象默认生命周期超过15,就会去养老区养老。
3.5.6分代收集思想 Minor GC
、Major GC
、Full GC
JVM
在进行GC
时,并非每次都新生区和老年区一起回收的,大部分时候回收的都是指新生区。针对HotSpot VM
的实现,它里面的GC
按照回收区域又分为两大类型:一种是部分收集,一种是整堆收集。
部分收集:不是完整收集整个java
堆的垃圾收集。其中又分为:
新生区收集(Minor GC/Yong GC
):只是新生区(Eden,S0,S1
)的垃圾收集。
老年区收集(Major GC/Old GC
):只是老年区的垃圾收集。
混合收集(Mixed GC
):收集整个新生区以及部分老年区的垃圾。
整堆收集(Full GC
):收集整个java
堆和方法区的垃圾收集。
整堆收集出现的情况:
System.gc()
时
老年区空间不足
方法区空间不足
开发期间尽量避免整堆收集。
3.5.7 TLAB
机制
为什么有TLAB
(Thread Local Allocation Buffer)
堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据。
由于对象实例的创建在JVM
中非常频繁,因此在并发环境下从堆区中划分 内存空间是线程不安全的。
为避免多个线程操作同一个地址,需要使用加锁等机制,进而影响分配速度。
什么是TLAB
?
TLAB
的全称是Thread Local Allocation Buffer
,即线程本地分配缓存区,这是一个线程专用的内存分配区域。
如果设置了虚拟机参数-XX:UseTLAB
,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。
JVM
使用TLAB
来避免多线程冲突,在给对象分配内存时,每个线程使用自己的TLAB
,这样就可以避免线程同步,提高了对象分配的效率。
TLAB
空间的内存非常小,缺省情况下仅占有整个Edon
空间的1%,也可以通过选项-XX:TLABWasteTargetPercent
设置TLAB
空间所占用Eden
空间的百分比大小。
3.5.8 堆空间的参数设置
官网地址:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
-XX:+PrintFlagsInitial
查看所有参数的默认初始值
-XX:+PrintFlagsFinal
查看所有参数的最终值(修改后的值)
-Xms
初始堆空间内存(默认为物理内存的1/64)
-Xmx
最大堆空间内存(默认为物理内存的1/4)
-Xmn
设置新生代的大小(初始值及最大值)
-XX:NewRatio
配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio
设置新生代中Eden
和S0/S1
空间比例
-XX:MaxTenuringTreshold
设置新生代垃圾的最大年龄
-XX:+PrintGCDetails
输出详细的GC
处理日志
3.5.9 字符串常量池
字符串常量池为什么要调整位置?
JDK7
中将字符串常量池放到了堆空间中。因为永久代(以前的方法区)的回收效率很低,在Full GC
的时候才会执行永久代的垃圾回收,而Full GC
是老年代的空间不足、永久代不足时才会触发。
这就导致String Table
(字符串常量池)回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
3.6 方法区(元空间)
3.6.1 方法区的基本理解
方法区,是一个被线程共享的内存区域。其中主要存储加载的类字节码、class/method/field等元数据、static final常量、static变量、即时编译器编译后的代码等数据。另外,方法区包含了一个特殊的区域“运行时常量池”。
Java虚拟机规范中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但对于HotSpotJVM
而言,方法区还有一个别名叫做Non-Heap
(非堆),目的就是要和堆分开。
所以,方法区看做是一块独立于java
堆的内存空间。
方法区在JVM
启动时被创建,并且它的实际的物理内置空间中和Java堆区一样都可以是不连续的。
方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出的错误:java.lang.OutOfMemoryError.Metaspace
。(jdk7
之前)
例如:
public static void main(String[] args) {
String temp = "world";
for (int i = 0; i < Integer.MAX_VALUE; i++) {
String str = temp + temp;
temp = str;
str.intern();
}
}
关闭虚拟机,内存就会被释放掉。
方法区,栈,堆的交互关系
3.6.2 方法区大小设置
Java方法区的大小不必是固定的,JVM
可以根据应用的需要动态调整。
元数据区大小可以使用参数-XX:MetaspaceSize
和-XX:MaxMataspaceSize
指定,替代上述原有的两个参数。
默认值依赖于平台,Windows下,-XXMetaspaceSize
是21 MB。
-XX:MaxMetaspaceSize
的值是-1,级没有限制。
这个-XX:MetaspaceSize
初始值是21 M 也称高水位线,一旦触及,就会触发Full GC
(整堆收集)。
因此为了减少Full GC
,那么这个-XX:MetaspaceSize
可以设置一个较高的值。
3.6.3方法区的内部结构
方法区它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存,运行常量池等。
3.6.4 方法区的垃圾回收
方法区的垃圾回收主要回收两部分内容:运行时常量池中废弃的常量和不再使用的类型。
回收废弃常量与回收Java堆中的对象非常类似。(关于常量的回收比较简单,重点是类的回收)
下面也称类卸载
判定一个类型是否属于“不在使用的类”,需要满足以下三个条件:
1.该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
2.加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi
、JSP
的重加载等,否则通常是很难达成的。
3.该类对应的java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
4.本地方法接口
哪些方法可以没有方法体?
抽象方法 和 本地方法 没有方法体。
4.1 什么是本地方法
简单来讲,一个**Native Method
(本地方法)**就是一个java
调用非java
代码的接口,一个Native Method
是这样的一个java
方法:该方法的底层实现是由非Java语言实现,比如C,这个特征并非java
特有,很多其他编程语言都有这一机制。
在定义一个native method
时,并不提供实现体(有些像定义一个Java interface),因为其实现体是由非Java语言在外面实现的。
关键字 native 可以与其他所有的 java
标识符连用,但是 abstract除外。
4.2 为什么要使用 Native Mrthod
Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。
1.与Java环境外交互:
有时java
应用需要与java
外面的环境交互,这是本地方法存在的主要原因。你可以想想java
需要与一些底层系统,如擦偶系统或某些硬件交换信息时的情况。本地方法正是这样的一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解java
应用之外的繁琐细节。
2.与操作系统交互(比如线程最后要回归于操作系统线程)
JVM
支持着java
语言本身和运行库,它是java
程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用java
实现了jre
的与底层系统的交互,甚至jVM
的一些部分就是用C写的。还有,如果我们要使用一些java
语言本身没有提供封装的操作系统特性时,我们也需要使用本地方法。
3.Sun' s Java
Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。jre
大部分是用java
实现的,它也通过一些本地方法与外界交互。例如:类java.lang.Thread
的setPriority()
方法是用Java实现的,但是它实现调用的是该类里的本地方法setPriority0()
。这个本地方法是用C实现的,并被植入JVM
内部,在Windows 95 的平台上,这个本地方法最终将调用 Win32 setPriority()API
。这是一个本地方法的具体实现由JVM
直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library) 提供,然后被JVM
调用。
5.执行引擎
5.1概述
1.执行引擎是Java虚拟机核心的组成部分之一。
2.JVM
的主要任务是负责装载字节码到其内部,但字节码不能直接运行在操作系统上。因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM
所识别的字节码指令、符号表,以及其他辅助信息。
3.那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM
中的执行引擎充当了将高级语言翻译为机器语言的译者。
注意区分概念:
1.前端编译:从Java程序员-字节码文件的这个过程叫前端编译。
2.执行引擎这里有两种行为:一种是解释执行,一种是编译执行(这里的是后端编译)。(两套机制(解释和编译)并存)
5.2 什么是解释器?什么是 JIT
编译器?
解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
**JIT
(Just In Time Compiler)编译器:**就是虚拟机将源代码一次性直接编译成和本地机器平台相关的机器语言,但并不是马上执行。
5.3 为什么Java是半编译半解释型语言?
起初将Java语言定义为”解释执行“还是比较准确的。再后来,Java也发展出可以直接生成本地代码的编译器。现在JVM
在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。
原因:
JVM
设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式由高级语言直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。
解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行,执行效率低。
JIT
编译器将字节码翻译成本地代码后,就可以做一个缓存操作,存储在方法区的JIT
代码缓存中(执行效率更高了)。
是否需要启动JIT
编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。
JIT
编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能。
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”。
目前HotSpot VM
所采用的热点探测方式是基于计数器的热点探测。
JIT
编译器执行效率高为什么还需要解释器?
1.当程序启动后,解释器可以马上发挥作用,响应速度快,省去编译的时间,立即执行。
2.编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间,但编译为本地代码后,执行效率高。就需要采用解释器与即时编译器并存的架构来换取一个平衡点。
6.垃圾回收
6.1 垃圾回收概述
6.1.1 概述
1.Java
和C++ 语言的区别,就在于垃圾收集技术和内存动态分配上,C++语言没有垃圾收集技术,需要程序员手动的收集。Java
有自动垃圾回收的机制。
2.垃圾收集,不是Java
语言的伴生产物。早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。
3.关于垃圾收集有三个讲点问题:
哪些内存需要回收?
什么时候回收?
如何回收?
4.垃圾收集机制是Java
的招牌能力,极大地提高了开发效率。
6.1.2什么是垃圾?
垃圾是指在运行程序中没有任何指针(引用类型变量)指向的对象,这个对象就是需要被回收的垃圾。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
6.1.3 为什么需要GC
?
1.对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产垃圾而从来不打扫一样。
2.除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM
将整理出的内存分配给新的对象。(内存碎片整理)
3.随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC
就不能保证应用程序的正常进行。
内存泄露:早期垃圾回收是由开发人员手动回收的,倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄露,垃圾对象永远无法清除,随着系统运行时间增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃。
6.1.4 Java
垃圾回收机制
6.1.4.1自动内存管理
官网介绍:
https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html
自动内存管理的优点:
自动内存管理,无需开发人员手动参与内存的分配 与回收,这样降低内存泄露和内存溢出的风险。
自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发。
6.1.4.2 关于自动内存管理的担忧
1.如果过度依赖于“自动”,最严重的的就会弱化Java
开发人员在程序出现内存溢出时定位问题和解决问题的能力。
2.此时,了解JVM
的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM
是如何管理内存后,我们才能够在遇见OutOfMemoryError
时,快速的根据错误异常日志定位问题和解决问题。
3.当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统
达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
6.1.5 应该关心那些区域的回收?
垃圾回收期可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收,其中,Java
堆是垃圾收集器的工作重点。
从次数上讲:
频繁收集年轻代
较少收集老年代
基本不收集元空间(方法区)
6.2垃圾回收相关算法
6.2.1垃圾标记阶段算法
6.2.1.1标记阶段的目的
垃圾标记阶段:主要是为了判断对象是否存活。
1.在堆里存放着几乎所有的Java对象实例,在GC
执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,GC
才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
2.当一个对象已经不在被任何的存活对象继续引用时,就可以宣判为已经死亡。
3.判断对象存活一般有两种方式:引用计数算法 和 可达性分析算法。
6.2.1.2 引用计数算法
1.引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
2.对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行垃圾回收。
3.优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
4.缺点:
1.它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
2.每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
3.引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条知名缺陷,导致在Java的垃圾回收器中没有使用这类算法。
6.2.1.3可达性分析算法
可达性分析算法:也可以称为根搜索算法、追踪性垃圾收集。
1.相对于引用技术算法而言,可达性分析算法不仅 同样具备实现简单和执行高效等特点,更重要的是该算法可以有效的解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
2.相较于引用计数算法,这里的可达性分析就是Java、C#选择的。这种类型的垃圾收集通常也叫追踪性垃圾收集(Tracing Garbage Collection)
可达性分析实现思路
所谓“GCRoots
”根集合就是一组必须活跃的引用
其基本思路如下:
1.可达性分析算法是以根对象集合(GCRoots
)为起始点,按照从上至下的方式搜索被根对象所连接的目标对象是否可达。
2.使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
3.如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象。
4.在可达性分析算法中,只有能够被根对象集合直接或间接连接的对象才是存活对象。
GC Roots
可以是哪些元素?
1.虚拟机栈中引用的对象
比如:各个线程被调用的方法中使用到的参数、局部变量等。
2.本地方法栈内JNI
(通常说的本地方法)引用的对象
3.方法区中类静态属性引用的对象
比如:Java类的引用类型静态变量
4.方法区中常量引用的对象
比如:字符串常量池(String Table)里的引用
5.所有被同步锁synchronIzed
持有的对象
6.Java
虚拟机内部的引用
基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException
、OutOfMemoryError
),系统类加载器。
总结
1.简单一句话就是,除了堆空间的周边,比如:虚拟机栈、本地方法栈、方法区、字符串常量池等地方对堆空间进行引用的,都可以作为GC Roots
进行可达性分析。
2.除了这些固定的GC Roots
集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象"临时性"地加入,共同构成完成GC Roots
进行可达性分析。
2.除了这些固定的GC Roots
集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots
集合。比如:分代收集和局部回收。
小技巧
由于Root采用栈方式存放变量和指针,索引如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。
注意
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
这点也是导致GC
进行时必须“Stop The World”的一个重要原因。即使是号称(几乎)不会发生停顿的**CMS
收集器**中,枚举根结点时也是必须要停顿的。
6.2.1.4 对象的 finalization 机制
finalize() 方法机制
对象销毁前的回调函数:finalize()
当垃圾收集确定不再有对该对象的引用时,垃圾收集器在对象上调用该对象。
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前。总会先调用这个对象的finalize()方法。
finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
Object 类中 finalize() 源码
protected void finalize() throws Throwable { }
永远不要主动调用某个对象的 finalize() 方法,应该交给垃圾回收机制调用。理由包括下面三点:
1.在finalize()时可能会导致对象复活。
2.finalize
() 方法的执行时间是没有保障的,它完全由GC
线程决定,极端情况下,若不发生GC
,则finalize() 方法将没有执行机会。
3.一个糟糕的 finalize() 会严重影响GC
的性能。比如finalize是个死循环。
6.2.1.5 生存还是死亡?
由于 finalize() 方法的存在,虚拟机中的对象一般处于三种可能的状态。
如果从所有的根结点都无法访问到某个对象,说明对象已经不再使用了。一般来说,次对象需要东北回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它立即进行回收就是不合理的。为此,定义虚拟机中的对象可能的三种状态。如下:
**可触及的:**从根节点开始,可以到达这个对象。
**可复活的:**对象的所有引用都被释放,但是对象有可能在 finalize() 中复活。
**不可触及的:**对象的finalize() 被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。
以上3种状态中,是由于finalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。
具体过程
判定一个对象objA
是否可回收,至少要经历两次标记过程:
1.如果对象objA
到GC Roots
没有引用链,则进行第一次标记。
2.进行筛选,判断此对象是否有必要执行finalize()方法
如果对象objA
没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA
被判定为不可触及的。
如果对象objA
重写了finalize()方法,且还未执行过,那么objA
会被插入到F-Queue
队列中,有一个虚拟机自动创建的、低优先级的Finalizer
线程触发其finalize()方法执行。
finalize()方法是对象逃脱死亡的最后机会,稍后GC
会对F-Queue
队列中的对象进行第二次标记。如果objA
在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA
会被移出“即将回收“集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize()方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize()方法只会被调用一次。
6.2.2垃圾回收阶段算法
当成功区分出内存中存活对象和死亡对象后,GC
接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。目前在JVM
中比较常见的三种垃圾收集算法是:
标记-清除算法(Mark-Sweep)
复制算法(Copying)
标记-压缩算法(Mark-Compact)
6.2.2.1标记-清除算法
执行过程
当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
**标记:**Collector从引用根结点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。(注意:标记的是被引用的对象,也就是可达对象,并非标记的是即将被清除的垃圾对象)。(可达性分析算法)
**清除:**Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。(有一个集合,记录垃圾位置,有新对象时,覆盖垃圾位置)
**优点:**简单
**缺点:**产生内存碎片,效率不算高。
在进行GC
的时候,需要停止整个应用程序,用户体验较差
这种方式清理出来的空闲内存是不连续的,产生内存碎片,需要维护一个空闲列表。(空闲列表-记录垃圾对象地址)。
注意:何为清除?
这里的清除不是置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,,如果够,就存放(也就是覆盖原有地址)。
6.2.2.2 复制算法
为了解决标记-清除算法在垃圾收集效率方面的缺陷,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最终完成垃圾回收。
有两块内存区域,有一块是空闲的。
垃圾回收时,把没有回收掉的对象,复制到空闲内存区域,清空原来的区域。
**优点:**没有标记和清除过程,实现简单,运行高效。
复制过去后保证空间的连续性,不会出现“碎片”问题。
对于少量对象,运行快,不会产生碎片
**缺点:**需要两倍的内存空间,对象发生复制,大量对象效率低
应用场景:
如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,效率较高。
老年代大量的对象存活,那么复制的对象将会有很多,效率会很低
在新生代,对常规应用的垃圾回收,一次通常可以回收70%-90%的内存空间。
回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
6.2.2.3标记-压缩算法
背景
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM
的设计者需要在此基础之上进行改进。
标记压缩算法执行过程
第一阶段和标记清除算法一样,从根结点开始标记所有被引用对象
第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间。
**标记:**可达性分析算法
**压缩:**将存活的对象整理到内存一端,按顺序排放。
**优点:**不会产生内存碎片(消除了标记-清除算法中昌盛内存碎片的问题),消除了复制算法中内存减半的高额代价。
**缺点:**从效率上讲,标记-整理算法要低于复制算法。
移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
移动过程中,需要全程暂停用户应用程序。即:STW
总而言之,效率低。
标记-压缩算法与标记-清除算法的比较
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以称之为标记-清除-压缩算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法(空闲列表记录位置),标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
6.2.2.4垃圾回收算法小结
效率上来说,复制算法最快,但是浪费太多内存。
为了兼顾效率,空间开销,是否移动对象这三个条件,标记-压缩(标记-整理)算法相对更平滑一点,虽然效率上最慢,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。
6.2.2.5分代收集算法/分区算法
年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。(复制算法速度最快)
老年代特点:区域较大,对象生命周期长,存活率高,回收不急年轻代频繁。(标记-清除或者是标记-清除与标记-整理的混合实现)
不同的区域使用不同的垃圾回收算法。
分带的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。
6.2.2.6增量收集算法和分区算法
以上垃圾回收算法,在垃圾回收的过程中,应用程序将处于一种Stop the World 的状态。在Stop the World状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集算法的诞生。
增量收集算法基本思想
垃圾回收时,用户线程需要停止(STW
)
每次回收部分垃圾,回收线程和用户线程交替执行,减少用户线程停顿时间。
总的来说,增收收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善出路,允许垃圾收集线程以分阶段的方式(让垃圾收集线程和应用程序线程交替执行)完成标记、清理或复制工作。
**缺点:**切换次数开销增大,每次回收垃圾少。
6.3垃圾回收相关概念
6.3.1 System.gc()
的理解
在默认情况下,通过System.gc()
或者Runtime.getRuntime().gc()
的调用,会显示触发Full GC
,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。
显示的调用gc()
,不一定立即执行垃圾回收(需要CPU加载,不能确保立即生效)。
java
中的垃圾回收时自动的,垃圾回收也是一个线程,需要CPU加载
6.3.2内存溢出与内存泄漏
内存溢出和内存泄漏
内存溢出:堆,栈,方法区
内存泄漏:IO(没有close())、数据库连接(没有close()),有些对象已经并不会再使用了,但是垃圾回收不能将其清除。 一直占用内存空间。
内存溢出
Javadoc
中对OutofMemoryError
的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
内存溢出是引发程序崩溃的罪魁回首之一。(出现OOM
异常)
内存泄漏
内存泄漏也称作“存储泄漏”。严格来说,只有对象不会再被程序用到了,但是GC
又不能回收它们的情况,才叫内存泄漏。
尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OutofMemory
异常,导致程序崩溃。
常见例子
单例模式
单例的生命周期和应用程序是一样长的,所以在单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的发生。
一些提供close()的资源未关闭导致内存泄漏
数据库连接dataSourse.getConnection()
,网络连接socket和io
连接必须手动close,否则是不能被回收的。
6.3.3 Stop the World
Stop-the-World,简称STW
,指的是**GC
事件发生过程中,会产生应用程序的停顿**。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW
。
为什么发生停顿?
垃圾回收标记算法需要对某一个时间节点上的内存中对象状态进行分析,定位垃圾。如果一直变化的,无法定位垃圾对象。
STW
是所有垃圾回收器都不可避免的。
6.3.4 对象引用
6.3.4.1概述
我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。
在JDK1.2
版之后,Java对引用的概念进行了扩充,将引用分为:
强引用(Strong Reference)
软引用(Soft Reference)
弱引用(Weak Reference)
虚引用(Phantom Reference)
这4种引用强度依次逐渐减弱。除强引用外,其他3种引用均可以在java.lang.ref
包中找到它们的身影。如下图,显示了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们。
Reference 子类中只有终结器引用是包内可见的,其他3种引用类型均为public,可以在应用程序中直接使用。
**强引用(Strong Reference):**最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。
无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。宁可报OOM
,也不会GC
强引用。
强引用是不会被回收的,即使内存不够用了,报OOM
public class StrongReferenceTest {
public static void main(String[] args) {
StringBuffer str = new StringBuffer ("Hello,world");
StringBuffer str1 = str;
str = null;
System.gc();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(str1);
}
}
**软引用(Soft Reference):**在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
使用SoftReference
进行标记,当垃圾回收后,内存不够用时,对软引用进行回收。对软引用对象回收后,内存依然不够使用,报OOM
。
Object obj = new Object();// 声明强引用
SoftReference<Object> sf = new SoftReference<>(obj);
obj = null; //销毁强引用
**弱引用(Weak Reference):**被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被软引用关联的对象。
使用WeakReference
标记,发现即回收。
// 声明强引用
Object obj = new Object();
WeakReference<Object> sf = new WeakReference<>(obj);
obj = null; //销毁强引用
**虚引用(Phantom Reference):**一个对象是否有虚引用的存在,完全不会对其生存空间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
使用PhantomReference
标记,相当于没有引用,随时可以被回收。
// 声明强引用
Object obj = new Object();
// 声明引用队列
ReferenceQueue phantomQueue = new ReferenceQueue();
// 声明虚引用(还需要传入引用队列)
PhantomReference<Object> sf = new PhantomReference<>(obj, phantomQueue);
obj = null;
6.4垃圾回收器
6.4.1 垃圾回收器概述
垃圾回收器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM
来实现。
由于JDK
的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC
版本。
从不同角度分析垃圾收集器,可以将GC
分为不同的类型。
6.4.2垃圾回收器分类
按照回收线程数量分类,可以分为串行垃圾回收器和并行垃圾回收器。
串行:只有一个垃圾回收线程
并行:有多个垃圾回收线程,可以并行执行。提升了吞吐量。
使用回收线程时,采用独占式,此时工作线程被暂停,直至垃圾收集工作结束,使用“stop the world”机制。
按照工作模式分类,可以分为并发式垃圾回收器和独占式垃圾回收器。
并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。
按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器。
6.4.3性能指标
吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间:程序的运行时间+垃圾回收的时间)
垃圾收集开销:垃圾收集所用时间与总运行时间的比例。
暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
收集频率:相对于应用程序的执行,收集操作发生的频率。
内存占用:Java堆区所占的内存大小。
快速:一个对象从诞生到被回收所经历的时间。
6.4.4 HotSpot
垃圾收集器
串行回收器(只有一个垃圾回收线程):Serial,Serial old
并行回收器(有多个垃圾回收线程):ParNew
,Parallel scavenge,Parallel old
并发回收器:CMS
、G1
新生代收集器:Serial,ParNew
,Parallel scavenge;
老年代收集器:Serial old,Parallel old,CMS
;
整堆收集器:G1
(现在的虚拟机使用)
1.Serial
垃圾收集器(单线程)
只开启一条GC
线程进行垃圾回收,并且在垃圾收集过程中停止一切用户线程(Stop The World)。
一般客户端应用所需内存较小,不会创建太多对象,而且堆内存不大,因此垃圾收集器回收时间短,即使在这段时间停止一切用户线程,也不会感觉明显卡顿。因此Serial
垃圾收集器适合客户端使用。
由于Serial
收集器只是用一条GC
线程,避免了线程切换的开销,从而简单高效。
2.ParNew
垃圾收集器(多线程)
ParNew
是Serial
的多线程版本。由多条GC
线程并行地进行垃圾清理。但清理过程依然需要 Stop The World。
Parial
追求“地停顿时间”,与Serial
唯一区别就是使用了多线程进行垃圾收集,在多CPU环境下性能比Serial
会有一定程度的提升;但线程切换需要额外的开销,因此在单CPU环境中表现不如Serial
。
3.Parallel Scavenge
垃圾收集器(多线程)
Parallel Scavenge 和 ParNew
一样,都是多线程、新生代垃圾收集器。但是两者有巨大的不同点:
Parallel Scavenge:追求CPU吞吐量,能够在较短时间内完成指定任务,因此适合没有交互的后台计算。
ParNew
:追求降低用户停顿时间,适合交互式应用。
吞吐量 = 运行用户代码时间 / (程序运行时间 + 垃圾收集时间)
追求高吞吐量,可以通过减少GC
执行实际工作的时间,然而,仅仅偶尔运行GC
意味着每当GC
运行时将有许多工作要做,因为在此期间积累在堆中的对象数量很高。单个GC
需要花更多的时间来完成,从而导致更高的暂停时间。而考虑到低暂停时间,最好频繁运行GC
以便更快速完成,反过来又导致吞吐量下降。
4.Serial Old
垃圾收集器(单线程)
Serial Old 收集器是 Serial 的老年代版本,都是单线程收集器,只启用一条GC
线程,都适合客户端应用。它们唯一的区别就是:Serial Old工作在老年代,使用“标记-整理”算法;Serial 工作在新生代,使用“复制”算法。
5.Parallel Old
垃圾收集器(多线程)
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求CPU 吞吐量。
6.CMS
回收器(低延迟)
**CMS
(Concurrent Mark Sweep,并发标记清除)**收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和GC
线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。
初始标记:Stop The World
,仅使用一条初始标记线程对所有与GC Roots
直接关联的对象进行标记。
并发标记:使用多条标记线程,与用户线程并发执行。次过程进行可达性分析,标记出所有废弃对象。速度很慢。
重新标记:Stop The World,使用多条标记线程并发执行,将刚才并发标记过程中新出现的废弃对象标记出来。
并发清除:只使用一条GC
线程,与用户线程并发执行,清除刚才标记的对象。这个过程非常耗时。
并发标记与并发清除过程耗时最长,且可以与用户线程一起工作,因此,总体上说,CMS
收集器的内存回收过程是与用户线程一起并发执行的。
CMS
的优点:
并发收集
低延迟
CMS
的弊端:
1.会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC
。
2.CMS
收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
3.CMS
收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure”失败而导致另一次Full GC
的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS
将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC
时释放这些之前未被回收的内存空间。
产生碎片。并发式占用用户线程。
7.G1
(Garbage First)回收器(区域划分代式)
分区垃圾收集
保证低延迟,提高吞吐量
将堆的每个区域进行细分,分成多个区域吗,回收时可以避免整堆回收,分区域进行。
由于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC
就不能保证应用程序正常进行,而经常造成STW
的GC
又跟不上实际的需求,所以才会不断地尝试对GC
进行优化。G1
(Garbage-First)垃圾回收器是在Java7 update 4
之后引入的一个引得垃圾回收器,是当今收集器技术发张的最前沿成果之一。
G1
(Garbage-First)是一款面向服务器端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC
停顿时间的同时,还兼具高吞吐量的性能特征。
从整体上看,G1
是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
问题:
一个对象和它内部所引用的对象可能不在同一个Region中,那么当垃圾回收时,是否需要扫描整个堆内存才能完整地进行一次可达性分析?
不需要。每个Region都有一个Remembered Set,用于记录本区域中所有对象引用的对象所在的区域,进行可达性分析时,只要在GC Roots
中再加上Remembered Set 即可防止对整个堆内存进行遍历。
如果不计算维护 Remember Set 的操作,G1
收集器的工作过程分为以下几个步骤:
初始标记:Stop The World,仅使用一条初始标记线程对所有与GC Roots
直接关联的对象进行标记。
并发标记:使用一条标记线程与用户线程并发执行。此过程进行可达性分析,速度很慢。
最终标记:Stop The World,使用多条标记线程并发执行。
筛选回收:回收废弃对象,此时也要Stop The World,并使用多条筛选回收线程并发执行。