1. JVM 简介
JVM「Java Virtual Machine」简称Java虚拟机
虚拟机:通过软件模拟,具有完整功能的运行在一个完全隔离的环境中的操作系统
常见虚拟机
JVM与其它两个虚拟机区别:
- VMWare 和 Virsual Box 只是通过软件模拟物理 CPU 指令集,物理系统中会有很多寄存器
- JVM 通过软件模拟 Java字节码 指令集,保留了主要寄存器,其它寄存器进行了裁剪
Java虚拟机发展历史「来自百度百科」
时间 | Java | JDK发展简介 | JVM | JVM发展简介 |
---|
1996 | JDK1.0 | | Sun公司发布了世界上第一款商业java虚拟机Sun Classic vm | 使用外挂的JIT「编译器,JIT会接管虚拟机的执行系统,解释器就不再工作,也不能和编译器配合工作」存粹的解释执行 现在Hotspot内置了此虚拟机 | 1997 | JDK1.1 | AWT、内部类、JDBC、RMI、反射(Java的核心) RMI:远程方法调用(Remote Method Invocation)。 能够让在某个java虚拟机上的对象像调用本地对象一 样调用另一个java 虚拟机中的对象上的方法 | | | 1998 | JDK1.2 | JDK1.2开始成为Java2「J2SE,J2EE,J2ME」并加入了Swing Collection | Solary Exact VM(仅存在了很短的时间) | JIT和解释器混合执行 Accurate Memory Management 精确内存管理,数据类型敏感,提升GC性能 Exact 具备现代高性能虚拟机的雏形:1??热点探测(将热点代码编译为字节码加速程序执行)2??编译器与解析器混合工作模式。 只在Solaris平台短暂使用,其他平台上还是 classic vm 英雄气短,终被Hotspot虚拟机替换 | 2000 | JDK1.3 | | HotSpot作为默认虚拟机发布 | 目前HotSpot占用绝对的市场地位,称霸武林。不管是现在仍在广泛使用JDK6,还是使用比较多的JDK8中,默认的虚拟机都是HotSpot 名称中的HotSpot指的就是它的热点代码探测技术。它能通过计数器找到最具编译价值的代码,触发即时编译(JIT)或栈上替换;通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡 | 2002 | JDK1.4 | Assert,正则表达式,NIO,IPV6,日志API,加密类库,异常链,XML解析器等 | Classic VM退出历史舞台 | | 2004 | JDK1.5 | 泛型,注解,装箱,枚举,可变长参数,Foreach循环 | | 虚拟机层面的看法:改进了Java内存模型(JMM),提供了JUC并发包 | | JDK1.6 | 更新内容:脚本编程的支持(动态语言支持),JDBC4.0,Java编译器API,微型Http服务器API等 | | 虚拟机层面:锁与同步,垃圾收集,类加载等算法的改动 | 2011 | JDK1.7 | G1收集器(Update4才正式发布) 加强对非Java语言的调用支持 升级类加载器架构 64位系统压缩指针 NIO2.0 | | | 2014 | JDK1.8 | Lamda表达式 语法增强 Java类型注释等 | | |
补充部分的JVM
-
JRockit
使用 JRockit 产品,客户已经体验到了显著的性能提高(一些超过了70%)和硬件成本的减少(达 50%) 优势:全面的Java运行时解决方案组合 JRockit面向延迟敏感型应用的解决方案 JRockit Real Time 提供以毫秒或微秒级的JVM响应时间,适合财 务、军事指挥、电信网络的需要 MissionControl服务套件,它是一组以极低的开销来监控、管理和分析生产环境中的应用程序的工具;2008,BEA被Oracle收购 Oracle表达了整合两大优秀虚拟机的工作,大致在JDK8中完成。整合的方式是在HotSpot的基础上,移植 JRockit 的优秀特性 -
J9 JVM
- 全称:IBM Technology for Java Virtual Machine,简称IT4J,内部代号:J9。 市场定位于HotSpot接近,服务器端、桌面应用、嵌入式等多用途JVM,广泛用于IBM的各种Java产品
目前,有影响力的三大商用虚拟机之一,也号称是世界上最快的Java虚拟机(在IBM自己的产品上稳定);2017年左右,IBM发布了开源 J9 VM,命名 OpenJ9,交给Eclipse基金会管理,也称为Eclipse OpenJ9 -
Taobao JVM「国产研发」
-
由 AliJVM 团队发布。阿里,国内使用Java最强大的公司,覆盖云计算、金融、物流、电商等众多领域, 需要解决高并发、高可用、分布式的复合问题。有大量的开源产品。 基于OpenJDK 开发了自己的定制版本AlibabaJDK,简称AJDK。是整个阿里JAVA体系的基石; 基于OpenJDK HotSpot JVM发布的国内第一个优化、深度定制且开源的高性能服务器版Java虚拟机,它具有以下特点
-
创新的GCIH(GC invisible heap)技术实现了off-heap,即将生命周期较长的Java对象从heap中移到heap之外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。 -
GCIH中的对象还能够在多个Java虚拟机进程中实现共享。 -
使用crc32指令实现JVM intrinsic降低JNI的调用开销; -
PMU hardware的Java profiling tool和诊断协助功能; -
针对大数据场景的ZenGC。 taobao JVM应用在阿里产品上性能高,硬件严重依赖intel的cpu,损失了兼容性,但提高了性能,目前已经在淘宝、天猫上线,把Oracle官方JVM版本全部替换了 -
JVM和《Java虚拟机规范》
以上的各种 JVM 版本,比如 HotSpot 和 J9 JVM,都可以看做是不同厂商实现 JVM 产品的具体实现,而它们(JVM)产品的实现必须要符合《Java虚拟机规范》,《Java虚拟机规范》是 Oracle 发布 Java 领 域最重要和最权威的著作,它完整且详细的描述了 JVM 的各个组成部分
-
Java与JVM发展中的历史事件
HotSpot是使用最广泛的虚拟机
HotSpot最早为Longview Tecnologies开发,被SUN收购
2006:Java开源,并建立OpenJDK。HotSpot成为SUN JDK和OpenJDK中所带的虚拟机
2008:Oracle收购BEA得到JRockit VM
2010:Oracle收购SUN得到HotSpot
Orcale宣布在JDK8时整合HotSpot和JRockit VM,优势互补。在HotSpot的基础上移植JRockit的优秀特性
2. JVM 运行流程
JVM是运行 Java 的基础,也是实现一次编译,到处运行的关键,那么 JVM 是如何执行的呢?
- 程序在执行之前需要先把java代码转换为字节码「class文件」
- JVM通过 类加载器「Class Loader」 会把字节码文件加载到内存中的 运行时数据区「Run Time Data Area」
- 字节码文件是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行
- **命令解析器执行引擎「Execution Engine」**会把字节码文件翻译成底层操作系统指令交付给 CPU 去执行
- 执行过程需要调用其它语言的 本地接口「Native Interface」
总结来看,JVM主要通过以下4部分来运行Java程序的
- 类加载器「ClassLoader」
- 运行时数据区「RunTImeDataArea」
- 执行引擎「ExecutionEngine」
- 本地库接口「NativeInterface」
3. JVM 运行时数据区
JVM运行时数据区域也也称内存布局,但需要注意的是它和Java内存模型「JJavaMemoryModel简称JMM」不同,属于完全不同的两个概念。
3.1 程序计数器「线程私有」
程序计数器的作用:用来指定当前线程执行字节码的行号指示器
是一个比较小的内存空间。如果当前线程执行的是一个Java方法,这个记录器会记录当前虚拟机中字节码执行的指令地址;如果当前线程执行的是一个 Native 方法,这个计数器为空
程序计时器是唯一一个JVM规范中没有指定 OOM「OutOfMemory:内存用完了,是程序错误Error而非是异常Exception」 情况的区域
造成OOM的原因:
- 分配的内存少
- 应用用的太多并且没有释放完而造成的浪费。就会有内存泄漏或者内存溢出
**内存泄漏:**申请使用完的内存没有释放,导致虚拟机不能再次使用该内存,此时这段内存就泄漏,因为申请者不适用了而又不能被分配给别人用
**内存溢出:**申请的内存超出了 JVM 能提供的内存大小
3.2 Java虚拟机栈「线程私有」
Java虚拟机的作用:Java虚拟机栈的生命周期和线程相同。Java虚拟机栈描述的是Java方法执行的内存模型
内存模型:每个方法在执行的时候都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口信息。
常说的堆内存,栈内存中,栈内存就是虚拟机栈
- 局部变量表:存放了编译器可知的各种基本数据类型「4类8种」,对象引用。局部变量表所需要的内存空间在编译器件完成分配,当进入一个方法时,这个方法需要分配多大的局部变量空间是完全确定的,在执行期间并不会改变局部变量表的大小。简单来说就是存放形参和局部变量
- 栈操作数:每个方法会生成一个先进后出的操作栈
- 动态链接:指向运行时常量池的对象引用
- 方法返回信息:PC寄存器的地址
什么是线程私有
由于JVM的多线程是通过 时间片轮转 的方式来推进整体任务。因此在单位时间内,一个处理器「多核处理器指的是一个内核」都只会执行一个线程中的一条指令,在线程切换过程中为了保留上下文环境,就通过程序计数器来记录当前线程锁执行字节码的指令地址行号。用以在下一个时间片轮转到自己的时候快速恢复运行状态。所以每个线程都需要一个独立的程序计数器,各个程序计数器之间互不影响,独立存储。
3.3 本地方法栈「线程私有」
本地方法栈和虚拟机栈类似。不同的是本地方法栈只给本地方使用;虚拟机栈只给JVM使用
3.4 堆「线程共享」
堆的作用:程序中所有创建的对象都保存在堆上「对象的引用保存在栈上」
我们常见的 JVM 参数设置 -Xms10m 最小启动内存是针对堆的,-Xmx10m 最大运行内存也是针对堆
ms:memory start
mx:memory maximum
堆里面又分为两个分区:新生代和老生代
新生代:有3个区域:Eden和两个survivor「S0/S1」放新建的对象
老生代:新生代中经历一定次数GC后还存活的对象就会被放入老生代
垃圾回收结束后,会把 Eden 中存活的对象放到一个未使用的 survior 中,并把当前的 Eden 和 survior 清除掉
3.5 方法区「线程共享」
方法区的作用:存放被虚拟机加载的类信息,常量,静态变量,即时编译后的代码等数据。
在《Java虚拟机规范中》把此区域称之为 “方法区” ,而在 HotSpot 虚拟机的实现中,在 JDK7 时此区域叫做永久代(PermGen),JDK 8 中叫做元空间(Metaspace)
永久代和元空间是HotSpot中方法区的实现,这三者关系就好比:对于一辆汽车来说,它有一个 动能提供装置,对于不同的汽车有不同的技术、对于燃油车来说它的动能提供装置就是 燃油气;对于电动车来说它的动能装置就是 电机。无论是燃油气还是电机,都是对 动能提供装置的实现,因此无论是永久代还是元空间,都是对 制动器 这个方法区的实现
JDK8中元空间变化
- 对于HotSpot来说,JDK8元空间的内存就是属于本地内存,这样元空间的大小就不再受限于JVM最大内存参数影响,而是与本地内存有关
- JDK8中把字符串常量移入到了堆内存中
3.6 运行时常量池
运行时常量池是方法区的一部分,主要存放了字面量与符号引用
字面量: 字符串「Java8移入到了堆中」,final常量,基本数据类型的值
**符号引用:**类和结构的完全限定名,字段的名称和描述符,方法的描述名称和描述符
3.7 内存布局中的异常问题
更改内存设置: 会有弹出窗口来设置最大内存
编辑自定义VM选项: 通过设置一些参数来自定义JVM的各种属性「功能更强」
3.7.1 Java堆溢出
Java堆用来存储对象的实例,只要不断的创建对象,在GC Root到对象之间有可达路径来避免这些对象被GC清除掉,那么对象达到最大堆容量的时候就会溢出
更改内存设置「局部修改」
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KxVpkhKk-1650966358032)(/Users/cxf/Desktop/MarkDown/images/设置一个JVM只有257MB内存.png)]
打开IDEA的内存状态「我的IDEA是2022.1版本」
网上很多之前版本的IDEA设置的打开内存窗口已经变了
编译自定义VM选项「全局修改」
程序代码
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class Main {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
ThreadPoolExecutor pool = new ThreadPoolExecutor(40, 1000, 0, TimeUnit.MILLISECONDS, new PriorityBlockingQueue<>(), new ThreadPoolExecutor.AbortPolicy());
pool.submit(new Runnable() {
@Override
public void run() {
while (true) {
list.add(new OOMObject());
}
}
});
}
}
list添加了很多匿名对象,每个对象创建并打印很多 Double。也没有释放「就是对象的引用变为空」
内存方面的错误,前面也提到过:主要是内存泄漏和内存溢出
**内存泄漏:**对象的释放并没有释放完全,每次都是少量的内存泄漏,日积月累后就会变得严重
**内存溢出:**内存对象确实应该存活。此时应该比较JVM堆参数与物理内存比较,是否把JVM堆调大;或者检查对象的生命周期是否过长
3.7.2 虚拟机栈和本地方法栈溢出
由于我们的 HosSpot把虚拟机栈和本地方法栈合二为一,因此对于HotSpot来说,只需要设置 Xss 来调整栈容量大小
虚拟机栈就报错两种异常
- StackOverFlow:线程请求的资源深度大雨虚拟机最大深度
- OOM:虚拟机在扩展栈时,无法获得到足够的内存空间
public class Main {
private static int stackLength = 0;
private static void test(){
++stackLength;
test();
}
public static void main(String[] args) {
try {
test();
}catch (Throwable e){
System.out.println("StackLength: "+stackLength);
e.printStackTrace();
}
}
}
StackLength: 21879
java.lang.StackOverflowError
at Main.test(Main.java:5)
借助StackOverFlow的报错信息,我们可以很容易知道具体的错误在哪儿,使用JVM默认的参数,程序正常调用包括递归深度在1000~2000之间是没有问题的
import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class Main {
private static void dontStop() {
while (true) {
System.out.println("q");
}
}
public static void main(String[] args) {
ThreadPoolExecutor pool = new ThreadPoolExecutor(8, 160, 0, TimeUnit.MILLISECONDS, new PriorityBlockingQueue<>(), new ThreadPoolExecutor.AbortPolicy());
pool.submit(Main::dontStop);
}
}
运行以上程序保存好当前所有文件,以免导致电脑卡死
3.7.3 小结
4.JVM 类加载
4.1 类加载过程
java学习过程中,我们经常 new对象,但是我们知道这个具体过程吗?对于一个类来说,它的是这样的。
其中前5步是固定的顺序并且也是类的加载过程,其中中间3步属于连接,所以对于类加载来说总共可以分为以下几个步骤。
- 加载
- 连接
- 验证
- 准备
- 解析
- 初始化
使用 和 卸载 这两个步骤不包含在类加载中
4.1.1 加载
加载「Loading」是类加载「ClassLoading」整个过程中的一个阶段
在Loading阶段有3个事情:
- 通过类的全限定名「也就是class类名」,来获取这个类的二进制字节流
- 将这个字节码的静态数据存储结构转换为方法区的运行时数据结构
- 在内存中生成一个
java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
4.1.2 验证
验证是连接的第一步,这一阶段保证的是Class字节码文件中所有信息符合《Java虚拟机规范》中的所有约束,保证这些代码运行后不会危害虚拟机自身安全
验证选项
- 文件格式验证
- 字节码验证
- 符号引用验证
4.1.3 准备
准备阶段是正式为类中定义的变量「静态变量」分配内存并设置类的初始值
比如有这样一段代码
public static int num=1;
num初始值为1而非0
4.1.4 解析
解析阶段就是java把常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程
4.1.5 初始化
初始化阶段,java虚拟机开始真正执行类中编写的java程序代码,将主导权交给java程序。初始化阶段就是执行类的构造器阶段
4.2 双亲委派模型
提到类的加载机制,不得不提的一个概念就是 “双亲委派模型”
站在Java虚拟机的角度来看:只存咋两种不同的类加载起
- 启动类加载起「Bootstrap ClassLoader」
- 这个类加载使用C++语言实现,是虚拟机自身的一部分
- 其它所有类的加载起「ClassLoader」
- 这些类加载器都有java语言实现,独立存在于虚拟机外部,并且全部继承自抽象类
java.lang.ClassLoader
站在java开发人员角度来看:类加载器就应当划分得更细致一些。自JDK1.2以来,jaba一直保持着3层类加载起,双亲委派的类加载架构器
4.2.1 什么是双亲委派模型
如果一个类加载起收到类加载的请求,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个类的加载请求「它的搜索范围中没有找到所需的类时,子类加载器才会尝试自己去完成加载」
- Bootstrap启动类加载器:加载JDK中lib目录中的java的核心类库,即$JAVA_HOME/lib目录
- Extension扩展类加载器:加载 lib/ext目录下的类
- Application应用程序类加载器:加载我们自己写的应用程序
- User自定义类加载器:根据自己的需求定制类加载器
4.2.2 双亲委派模型优点
- 避免重复加载类:比如A类和B类都有一个父类C类,那么当A启动时就会将C加载起来,那么B类进行加载的时候就不需要在重复加载C类了
- 安全性:使用双亲委派模型也可以保证Java的核心API不被篡改。如果没有使用双亲委派模型,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户自己提供的因此安全性就不能得到保证了
4.3 破坏双亲委派模型
双亲委派模型虽然有其优点,但在某些情况下也存在一定的问题。比如 Java 中 SPI 「Service Provider Interface服务提供接口」机制中的 JDBC 实现
SPI:是 Java 提供的一套用来被第三方实现或者扩展的接口,他可以用来启用框架扩展和替换组件。SPI的作用就是为这些被扩展的 API 寻找服务实现。
JDBC 中的 Driver 接口定义在 JDK 中,其实现各个数据库的服务商来提供,比如 MySQL 驱动包
import com.mysql.cj.jdbc.MysqlDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class JDBC {
private static String URL = "jdbc:mysql://127.0.0.1:3306/session1?characterEncoding=UTF8&useSSL=false";
private static String USERNAME = "root";
private static String PASSWORD = "0x11223344";
private static String Driver = "com.mysql.cj.jdbc.Driver";
private static DataSource dataSource = new MysqlDataSource();
static {
try {
Class.forName(Driver);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USERNAME, PASSWORD);
}
public static void main(String[] args) throws SQLException {
Connection connection = getConnection();
System.out.println(connection.getClass().getClassLoader());
System.out.println(Thread.currentThread().getContextClassLoader());
System.out.println(Connection.class.getClassLoader());
}
}
查看DriverManager在哪个外部库文件中
发现是 rt.jar 后,在找rt.jar
由双亲委派模型的加载流程可知:rt.jar 是有顶级父类 Bootstrap ClassLoader 加载的
而当我们进入 getConnection() 源码却发现,他在调用具体的类实现是,使用的是子类加载器「线程上下文加载器 Thread.currentThread().getCOntextClassLoader」 来加载具体的数据库数据包「如 MySQL 的 jar 包」
getConnection()
@CallerSensitive
public static Connection getConnection(String url, String user, String password) throws SQLException {
java.util.Properties info = new java.util.Properties();
if (user != null) {
info.put("user", user);
}
if (password != null) {
info.put("password", password);
}
return (getConnection(url, info, Reflection.getCallerClass()));
}
private static Connection getConnection(
String url, java.util.Properties info, Class<?> caller) throws SQLException {
ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
synchronized (DriverManager.class) {
if (callerCL == null) {
callerCL = Thread.currentThread().getContextClassLoader();
}
}
if (url == null) {
throw new SQLException("The url cannot be null", "08001");
}
println("DriverManager.getConnection(\"" + url + "\")");
SQLException reason = null;
for (DriverInfo aDriver : registeredDrivers) {
if (isDriverAllowed(aDriver.driver, callerCL)) {
try {
println(" trying " + aDriver.driver.getClass().getName());
Connection con = aDriver.driver.connect(url, info);
if (con != null) {
println("getConnection returning " + aDriver.driver.getClass().getName());
return (con);
}
} catch (SQLException ex) {
if (reason == null) {
reason = ex;
}
}
} else {
println(" skipping: " + aDriver.getClass().getName());
}
}
if (reason != null) {
println("getConnection failed: " + reason);
throw reason;
}
println("getConnection: no suitable driver found for " + url);
throw new SQLException("No suitable driver found for " + url, "08001");
}
这样一来就破坏了双亲委派模型,因为 DriverManaer 位于 rt.jar 包,由 BootStrap 类加载器加载,而其 Driver 接口的实现类是位于服务商提供的 rt.jar 包中,是由子类加载器「线程上下文加载器 Thread.currentThread.getContextCLassLoader()」来加载的,这样就破坏了双亲委派模型
双亲委派模型是所有类都应该交给父类 BootStrap来加载,显然 JDBC 并不是这样实现的,它的交互流程如图所示:
5. 垃圾回收「GC」
一个进程,在执行过程中,是需要使用计算机硬件资源的
**CPU:**分配和调度完全由操作系统内核负责
**内存:**创建的变量
创建变量就是从系统申请资源「内存其实是已经有JVM从系统申请好了,然后再交给代码来使用」。硬件资源是有限的,尤其是对于内存来说。一个机器上有很多很多的进程,每个进程都是需要使用到内存。如果我们的代码一直申请内存而不释放内存,显然是不行的
内存申请的时机是明确的「创建了个变量就是申请内存」
内存释放的时机不一定明确「创建了这个变量不在使用的时候才释放,C/C++里面,malloc 和 free」
**硬盘:**读写的文件
**网卡带宽:**socket 通信
**垃圾回收背景:**处理内存泄漏问题「服务器上如果出现内存泄漏,都是随着时间慢慢累积的泄漏」,JVM每隔一段时间会扫描以下内存中的这些变量,看看哪些变量不在使用了就直接释放。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ka3av6um-1650966358039)(/Users/cxf/Desktop/MarkDown/images/垃圾回收器.png)]
类似的 Java,Python,PHP,C# 这些主流编程流程语言采取的方案都是共同的方案:垃圾回收机制
可以极大避免出现内存泄漏的情况,内存释放时机是由 JVM/Python 解释器…等运行时环境来判定某个内存是否可以释放,如果可以释放就会自动释放操作
优点:方便
缺点:
- 需要消耗更多的资源
- 效率低一些,周期性的扫描。在下个扫描周期到来之前不在使用资源还没回收「回收不及时」
- STW问题:Stop the World。因为垃圾回收,引起了业务代码暂时不能执行「阻塞」
GC 在 java 中回收的是哪个部分的内存?
- 程序计数器
- Java虚拟机栈
- 本地方法栈
- 堆区
- 方法区
- 运行时常量池
程序计数器,栈,方法区:这三个区域的生命周期都和线程有关,同生共死,因此这三个区域的内存分配与回收有确定性
堆:属于最大的区域,属于整个进程中都能随时用到的内存。存放着几乎所有的对象实例,哪些对象 生,哪些对象 死 是比较模糊的,所以GC主要负责释放的就是这里
GC释放内存其实就在释放对象。申请对象是以 对象维度 申请的,释放就应该以 对象 来释放的。这个时候垃圾回收机制要考虑的事情:当前这个对象是否可以释放,判定这个对象是不是垃圾
为何GC不按照字节来管理内存?
虽然内存的基本单位是字节,但是相较于对象而言,管理每个字节较于管理每个对象方便许多
内存VS对象
在 Java 中,所有的对象都是要存在内存中的「也可以说内存中存储的是一个个对象」,因此我们将内存回收,也可以叫做死亡对象的回收
如何判定是不是垃圾: 就是看这个对象是否还有其它代码要使用「也就是有没有引用指向这个对象」
在 Java 中,要操作这个对象,势必要通过对象的引用。一个对象可以有一个引用,也可以有很多个引用,也可以没有引用
Test a=new Test();
Test b=new Test();
Test c=b;
new Test();
一个比较复杂的项目,代码有肯能错综复杂;一个对象可能有很多个引用指向,这些引用的生命周期又各不相同。
到底什么时候才真正没有引用指向呢?
5.1 死亡对象的判断算法
5.1.1 方案一:引用计数
这是一个简单粗暴的方法「Java中并没有使用这个办法」:给对象身上加一个计数器,每次创建一个引用指向了这个对象,计数器就++;每次有一个引用不指向这个对象,计数器就–。如果计数器为 0 了,说明这个对象就没有引用了,这个对象就是垃圾「Python使用」。
缺点:
- 空间利用率低:对象中需要额外的内存空间来保存这个引用计数的值。如果对象本身比较小「本来对象数据值有4字节,额外引用计数还得至少4字节」
- 会存在 “循环引用的问题”:导致计数器出现bug,判定垃圾就不准了
循环引用的BUG
public class Test {
public Object instance = null;
private static int _1MB = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
Test test1 = new Test();
Test test2 = new Test();
test1.instance = test2;
test2.instance = test1;
test1 = null;
test2 = null;
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
如果互相引用,每次有引用指向它们的时候就会计数+1
7,8行:test1–>1;test2–>1
9,10行:test1–>2;test2–>2
11,12行:test1–>1;test2–>1
这样显然是不合理的,因为对象已经不存在了,但是计数器却没有 归零
嵌套的循环引用
循环引用BUG
这个是《深入理解Java虚拟机》中所用的,但没有被Java采纳「而是在Python,PHP等编程语言的GC采用」。
面试中如果谈谈 Java 的回收机制,这个不要说。
为了解决计数问题的BUG,这里发明了可达性分析的方法
5.1.2 可达性分析
Java并不采用 引用计数 的方法来判断对象是否已 死,而采用 可达性分析来判断「C#」
核心思想: 通过一系列 “GC Root” 的对象做为起始点,从这些节点开始向下搜索,搜索走过的路径称为 “引用链”,当一个对象到 “GC Root” 没有任何引用链相连时「从 GC Root 到这个对象不可达」证明此对象是不可用的
对象 Object_E/F/G 之间虽然有联系,但是它们到 GC Root 是不可达的,所以会被判定为可回收对象
优点:很好地解决了 引用计数 中的 空间利用率,循环引用
缺点:每个周期都要扫描大量的对象「即使是二叉树这样的数据结构也架不住嚯嚯,在对象很多的时候开销很大」
Java中可以作为GC Root对象包含下面几种
- 虚拟机栈「栈帧中的本地变量表」中引用的对象
- 本地方法栈中JNI「Java Native方法」引用的对象
- 方法区中类静态属性引用的对象和常量引用的对象
从上面我们可以看出 “引用” 的功能,出了最早我们使用引用来查找对象「多线程中获取当前线程」,现在我们还可以使用引用来判断对象是否死亡。所以在JDK1.2是,Java对引用的概念做了扩充,将引用分为强引用「Strong Reference」,软引用「Soft Reference」,弱引用「Weak Ference」,虚引用「Phantom Reference」四种。这四种引用依次递减。
- 强引用:指的是程序代码中普遍存在的,类似于
Objec obj=new Object( 这类的引用,只要强引用还存在,垃圾回收器就永远不会回收掉被引用的对象实例 - 软引用:指的是用来描述一些还有用但是不是必须的对象。对于软引用关联的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行第二回收。如果第二次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了 Soft Reference类 来实现软引用
- 弱引用:弱引用也是用来描述非必需对象的。但是它的强度要弱于软引用。**被弱引用关联的对象只能生存到下一次垃圾回收之前。当垃圾回收器开始进行工作时,无论当前内容是否够用,都会回收掉只被弱引用关联到的对象。**在JDK1.2之后提供了 Weak Reference类 来实现弱引用
- 虚引用:虚引用也会被称为幽灵引用活着幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的:能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了 Phantom Reference类 来实现虚引用
5.2 垃圾回收算法
知道了哪些是死亡对象就可以标记出来了,然后我们就进行垃圾回收的操作。
5.2.1 标记-清除算法
这个是最基础的算法。操作分为 标记 和 清除 两部分。
思路:
- 首先标记出要回收的对象
- 在标记完成后统一回收所有被标记的对象「标记过程就是通过可达性分析标记的」。
后续的收集算法都是基于这种思路并对其不足加以改进升级
缺点:
- 效率问题:标记和清除这两个过程的效率都不高
- 空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多就可能导致以后再程序运行中需要分配连续大量内存的时候无法找到足够连续内存而不得不的提前触发一次GC
5.2.2 复制算法
复制算法解决 标记-清理 过程中 清理 的效率问题,相当于标记清除的改化版
思路:承接 标记-清理
- 将可用内存划分为大小相等的两块,每次只用其中的一块。
- 其中一块内存需要进行GC时,会将此区域还存活着的对象赋值到另一块内存上
- 再把已经使用过的内存区域全部清理掉。
优点
- 每次对整个半区进行内存回收,内存分配时就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可「执行简单,运行高效」
缺点
现在的上用虚拟机「包括HotSpot都采用这种收集算法来回收新生代」
新生代中的98%对象都是 “朝生暮死” 的,所以并不需要按照 1:1 的比例来换份内存空间,而是将内存「新生代内存」分为一个比较大的 Eden「伊甸园」 空间和两块比较小的 Survivor「幸存者」 空间,每次使用 Eden 和其中一块 Survivor「两个Survivor区域分别称为 From区,To区」。
当回收时将 Eden 和 Survivor 中还存活的对象一次性赋值到另一块 Survivor 空间上,最后清理掉 Eden 和刚才使用过的 Survivor 空间
当 Survivor 空间不够用时,需要依赖其他内存「老年代进行分配担保」
HotSpot 默认 Eden 与 Survivor 的大小比例是 8:1「Eden:Survivor From:Survivor To=8:1:1」
所以每次新生代可用内存空间为整个新生代容量的90%,而剩下的10%用来和存放回收后存活的对象
HotSpot实现的复制算法流程如下
- 当Eden区满的时候就会触发第一次 MinorGC,把还存活的对象拷贝到 SurvivorFrom;当Eden区再次触发 MinorGC 的时候,会扫描 Eden 区和 From 区,对两个区域进行垃圾回收,经过这次回收后还存活的对象会直接复制到 To 区,并将 Eden 和 From 区清空
- 当后续 Eden 又发生 MinorGC 的时候会对 Eden 和 To 区进行垃圾回收,存活的对象复制到 From 区,并将 Eden 和 To 区清空「From 和 To 类似于两个栈模拟实现队列或两个队列模拟实现栈一样两边倒腾数据」
- 部分对象会在 From 和 To 区中复制来复制去,如此交换 15 次「由JVM参数 MaxTenuringThreshold决定,默认值是15」,最终如果还是存活,就存入到老年代
优点
缺点
- 当遇到一些存活率较高的对象时,效率会很低尤其是老年代不能使用复制算法
5.2.3 标记整理
复制算法的改化版,针对老年代的特点,提出了一种称之为 标记整理算法。标记过程与 标记清除 过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存
示意图
优点
- 解决了复制算法针对存活率较高时进行多复制的操作问题,因此针对老年代效率会很高
针对新生代使用 复制算法,针对老年代使用 标记整理算法
5.2.4 分代算法
分代算法和上面的3种不同,分代算法是通过区域划分,实现不同区域和不同的垃圾回收策略,从而实现更好的垃圾回收「就好比在阿富汗问题上中国提出的 “阿人阿治“ 的方案」。
对于不同的情况和地域设置更符合当前的规则,从而实现更好的管理,这就是分代算法的设计思想「整合前3种算法」
当前 JVM 垃圾手机都采用的是 分代收集「Generation Collection」 算法,这个算法并没有新思想,只是根据对象存活周的不同将内存分为几块。一般是把 Java堆 分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;老年代中对象存活率更高,没有额外空间对它进行分配担保,就必须采用 标记整理 或者 标记清理 算法
5.2.5 哪些对象会进入新生代?哪些对象会进入老年代
- 新生代:一般创建的对象都会进入新生代
- 老年代:大对象和经历了 N 次 GC「一般默认是15」 垃圾回收依然存活下来的对象会从新生代移动到老年代
面试题:了解 MinorGC和FullGC么,这两种GC有何区别
- Minor GC:新生代GC。指的是发生在新生代的垃圾回收集。因为 Java对象 大多都是 “朝生暮死”,因此 MinorGC「采用复制算法」非常频繁,一般回收速度也很快
- FullGC:一般称为 老年代GC 或者 MajorGC。指的是发生在老年代的垃圾回收集。出现了 FullGC 经常会伴随至少一次的 MinorGC「并非绝对,在 ParallelScavenge 收集器中就有直接进行 FullGC 的策略选择过程」。FullGC 的速度一般会比 MinorGC 慢 10倍 以上
5.3 垃圾回收器
垃圾回收算法是内存回收的方法,那么垃圾收集器就是堆内存回收的具体实现
垃圾收集器作用:垃圾收集器是为了保证程序能够正常,持久运行的一种技术。将程序中不用的死亡对象也就是垃圾对象进行清除,从而保证了新对象能够正常申请到内存空间
HotSpot虚拟机随着不同版本推出的重要垃圾收集器
解释
- 连线:两个收集器之间存在连线,说明他们之间可以搭配使用
- 所处区域:表示他们属于新生代收集器还是老年代收集器
再了解收集器前先了解3个概念
- 并行「Parallel」:多个垃圾收集器线程并行工作,用户线程仍处于等待状态
- 并发「Concurrent」:用户线程于垃圾收集线程同时执行「不一定并行,可能会交替执行」,用户程序继续运行而垃圾收集程序则在另外一个 CPU 上
- 吞吐量:CPU用于运行用户代码的时间与CPU总消耗时间的比值
- 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
- 举例:虚拟机总运行了100分钟,用户代码99分钟,垃圾收集化掉1分钟,吞吐量就是99/100=99%
最后一解答:为何会有这么多垃圾收集器
自从有了 Java 语言就有了垃圾收集器,这么多垃圾收集器其实是历史发展的产物,而随着时间的发展,为了提升更高的性能,于是有了 Serial 多线程版本的垃圾收集器 ParNew。后来人们想要更高吞吐量的垃圾收集器,吞吐量是指单位时间内成功回收垃圾的数量,于是就有了吞吐量优先的垃圾收集器 ParallelScavenge「吞吐量优先的新生代垃圾收集器」 和 ParallelOld「吞吐量优先的老年代收集器」。随着技术的发展后来又有 CMS「ConcurrentMarkSweep」
垃圾收集器 , CMS 可以兼顾吞吐量和以获取最短回收停顿时间为目标的收集器,在JDK1.8「包含1.8」之前的 BS系统「BS:BrowserServer架构系统,CS:ClientServer架构系统」 的主流垃圾收集器,而在JDK1.8之后,又出现了第一个既不完全属于新生代也不完全属于老年代的垃圾收集器 G1「GarbageFirst」,G1 提供了基本不需要停止程序就可以收集垃圾的技术
5.3.1 Serial 新生代串行GC
Serial 收集器是最基本,发展历史最悠久的收集器,曾经「JDK1.3之前」是虚拟机新生代收集的唯一选择
- 特性:这个收集器是一个但线程的收集器,但它的 “但线程” 的意义并不仅仅说明它只会使用一个 CPU 或一个线程收集器去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有工作线程,知道它收集结束「Stop The World,译为停止整个程序,简称 STW」
- 应用场景:Serial 收集器是虚拟机运行在 Client 模式下的默认新生代收集器
- 优势:简单而高效「与其它收集器的单线程比」,对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程的收集效率
5.3.2 ParNew新生代串行GC
ParNew是Serial的多线程版本
除了使用多线程收集之外,其余行为包括 Serial 收集器可用的所有控制参数,收集算法,Stop The World,对象分配规则,回首策略等都与 Serial 收集器完全一致,这两种收集器也共用了相当多的代码
- 特性:Serial收集器的多线程版本
- 应用场景:ParNew收集器是许多运行在 Server 模式下的虚拟机中首选的新生代收集器
作为 Server 的首选收集器之中有一个与性能无关的很重要的原因是:除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作
在 JDK1.5 时期,HotSpot 推出了一款在墙角湖应用中几乎可以认为有划时代意义的垃圾收集器:CMS收集器,这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作
不幸的是:CMS作为老年代的收集器,却无法与 JDK1.4 中已经存在的新生代收集器 ParallelScavenge 搭配工作,所以在 JDK1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 其中的一个
ParNew收集器在单CPU环境中展现的效果绝对不会超过Serial,甚至由于存在线程切花的开销,该收集器在通过超线程技术实现的两个CPU环境中都不能100%地保证可以超越 Serial 收集器
然而随着可以使用的CPU数量的增加,它对于GC时系统资源的有效利用还是很有好处的
5.3.3 Parallel Scavenge新生代并行GC
新生代并行的多线程收集器收集器,使用复制算法
Parallel Scavenge使用两个参数控制吞吐量
XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间 XX:GCRatio 直接设置吞吐量的大小
直观上,只要最大的垃圾收集停顿时间越长,吞吐量会越高。但是 GC停顿时间的缩短是以牺牲吞吐量和新生代空间作为代价。比如:原来 16 秒收集一次,每次停顿 100ms,现在变成 5s 收集一次,每次停顿 70ms。停顿时间下降的同时,吞吐量也下降
-
应用场景:停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能够提升用户体验,而高吞吐量则可以高效率地利用 CPU时间,尽快完成大的运算任务,主要适合在后台运算而不需要太多的交互的任务 -
对比分析:
-
Parallel Scavenge收集器 VS CMD收集器 Parallel Scavenge特点是它的关注点和其它收集器不同,CMS等收集器关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge目标则是达到一个可控制的吞吐量「Throughput」 由于与吞吐量关系密切,Parallel Scavenge也经常被称为 “吞吐量优先收集器” -
Parallel Scavenge收集器 VS ParNew收集器 它们俩重要区别就是Parallel Scavenge具有自适应调节策略 -
GC自适应的调节策略 Parallel Scavenge收集器具有一个参数 XX:+UseAdaptiveSizePolicy ,当这个参数打开之后,就不需要手工指定新生代的大小,Eden 和 Survivor 区的比例,晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应调节策略「GC Ergonomics」
5.3.4 Serial Old老年代串行GC
- 特性:SerialOld时Serial收集器的老年代版本,它同样是一个但线程收集器,使用 标记-整理 算法
- 应用场景:
- Clietn模式:SerialOld收集器的主要意义也是在于给Client模式下的虚拟机使用
- Server:如果在Server模式下,主要有两个大用途
- JDK1.5以及之前的版本中与 ParallelScavenge 收集器搭配使用
- 作为CMD收集器的后备方案,在并发收集发生 ConcurrentModeFailure时使用
5.3.5 Parallel Old老年代并行GC
这个收集器是在JDK1.6中才开始提供的,在此之前,新生代的ParallelScavenge收集器一直处于比较尴尬的状态。原因是:如果选择了新生代ParallelScavenge收集器,老年代除了SerialOld收集器以外别无选择「ParallelScavenge无法与CMS搭配使用」。由于老年代SerialOld收集器在服务端性能上的 “拖累”,使用了 ParallelScavenge收集器也未必能充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParaNew+CMS的组合 “给力”。直到 ParallelOld收集器出现后,“吞吐量优先” 收集器终于有了比较名副其实的应用组合
5.3.6 CMS老年代并发GC
ConcurrentMarkSweep比较好用的垃圾收集器,尽可能的减少STW带来的影响
- 特性:收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者B/S「BrowseServer」系统的服务端。这类应用尤其重视服务的响应速度,希望系统停顿时间最短,译给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
CMS收集器是基于 标记-清除 算法实现的,它的运行过程相对于前面几种收集器来说更复杂一些,整个过程分为4步
-
初始标记「CMS initial mark」 初始标记仅仅只是标记一下GC Root能直接关联到的对象,速度很快,需要 Stop The World「停顿」 -
并发标记「CMS concurrent mark」 并发标记阶段就是进行GC Root Tracing「跟踪」 -
重新标记「CMS remark」 重新标记阶段是为了修正并发标记期间用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短,仍然需要 Stop The World「停顿」 -
并发清除:会清除标记的对象
对于整个过程中 耗时最长的并发标记和并发清除过程 都可以与用户线程一起工作,所以从总体上说,CMS收集器的内存回收过程是与用户线程一起并发执行的
-
优点:并发收集,低停顿 -
缺点:
-
CMS收集器对CPU资源非常敏感 其实面饼发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程「或者说CPU资源」而导致应用程序变慢,总吞吐量会降低 CMS默认启动的回收线程数「(CPU数量+3)/4」,也就是当CPU数量在 4 个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个「比如2个」时,CMS对用户程序的影响可能就变得很大 -
CMS收集器无法处理浮动垃圾 浮动垃圾:可能会出现 ConcurrentModeFailure 失败而导致另一次 MajorGC 的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。 也是由于垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。要是 CMS 运行期间预内存无法满足程序需要,就会先一次 ConcurrentModeFailure 失败,这是虚拟机将启动后备方案:临时启用 SerialOld 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了 -
CMS收集器会产生大量的空间碎片 CMS是一款基于 标记-清除 算法实现的收集器,这意味着收集结束时会有大量的空间碎片产生。空件碎片产生过多,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 MajorGC
5.3.7 G1GC「唯一一款全区域回收的垃圾回收器」
G1「GarbageFirst」垃圾回收器是在 heap memory 很大的情况下,把 heap 划分为很多很多的 region 块,然后并行的对其进行垃圾回收
G1垃圾回收器在清除实例所占用的内存空间后,还会做内存压缩。
G1垃圾回收器回收 region 的时候基本不会 STW,而是基于 most garbage 优先回收「整体来看是基于 标记-整理 算法,从局部两个 region 之间基于 赋值 算法」的策略来对 region 进行垃圾回收的。无论如何,G1收集器采用的算法都意味着:一个 region 有可能属于 Eden,Survivor或者Tenured 内存区域。
G1垃圾收集器还增加了一种新的区域:Humongous,主要存储大对象:大小超过一个 region 大小的 50% 的对象
G1年轻代垃圾收集
在G1垃圾收集器中,年轻代的垃圾回收过程使用复制算法。把 Eden 和 Survivor 区的对象复制到新的 Survivor
复制 Eden,Survivor
搬运到新的Survivor区
老年代垃圾收集
G1垃圾收集器也分为4个阶段,基本跟CMS垃圾收集器一样,但略有不同
- 初始标记「initial Mark」
- 同CMS垃圾收集器的 initial Mark 一样,G1也需要暂停应用程序的执行,他会标记从跟对象出发,再跟对象的第一层还字节点钟标记所有科大的对象。但是 G1 的垃圾收集器的 initial Mark 阶段和 MajorGC 疫病发生的,也就是说在 G1 种,我们不需要像在 CMS 那样单独暂停应用程序的执行来运行 initial Mark 阶段,而是在 G1 触发 MinjorGC 的时候一并将老年代上的 initial Mark 给做了
- 并发标记「Concurrent Mark」
- 这个阶段和CMS做的事情一样。但 G1 还多了一个件事情:如果在 ConcurrentMark 阶段中,发现哪些 Tenured region 中对象的存活率很小或者基本没有对象存活,那么 G1 就会在这个阶段将其回收掉而不用等到后面的 Clean up阶段。这也就是 GarbageFirst 名字的由来。同时,再改阶段,G1 会计算每个 region 对象的存活率方便后面的 Clean up阶段使用
- 最终标记「CMS中的Remark」
- 这个阶段和CMS做的事情一样。但采用的算法不同,G1 采用一种叫做 SATB「snapshot-at-the-begining」的算法能够在 Remark 阶段更快的标记可达对象
- 筛选回收「Clean up/Copy」
- 在G1种,没有CMS对应的 Sweep打扫 阶段。相反,它有一个 Clean up/Copy 阶段,在这个阶段中,G1 会挑选出那些对象存活率低的 region 进行回收,这个阶段也是和 MajorGC 一同发生的
准备复制Tenured「Old」,Eden和Survivor
搬运复制完成的的T/O到新的T/O区,E和S到新的S区
G1是一款面向 服务端应用 的垃圾收集器。HotSpot 开发团队赋予它的使命是未来可以替换 CMS 收集器,如果你的应用 追求低停顿,G1 可以作为首选;如果应用 追求吞吐量,G1带来的效果可能还没有ParallelScavenge效果好
5.4 总结:一个对象的一生
一个对象的一生:我是一个普通的 java对象,我出生在 Eden 区,在 Eden 区我还看到和我长得很像的小兄弟,我们在 Eden 区中玩了很长时间,有一天 Eden 区中的人实在太多了,我就被迫去了 Survivor 的 From 区「S0区」,我就开始了漂泊生涯,有时候在 Survivor 的 From区,有时候在 Survivor 区的 To 区「S1区」,居无定所,知道我18岁的时候,爸爸说:你是一个成年的对象了,该去社会闯一闯了。于是我就去了老年代区,老年代里,人很多,并且年龄都挺大,我在这里也认识了很多人。在老年代里,我生活了很多年「每经历一次GC就老了一岁」,最终入土为安被回收了。
6. JMM
JVM定义了一种 Java内存模型「Java Memory Model,JMM」来屏蔽各种硬件和操作锡荣的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。在此之前,C/C++直接使用武力硬件和操作系统的内存模型,因此会由于不同平台下的内存模型的差异有可能导致程序在一套平台上并发完全正常,在另一套平台上并发访问经常出错
6.1 主内存和工作内存
Java内存模型组要目标是定义程序中各个变量的访问规则,即在 JVM 中将变量存储到内存和内存中取出变量这样的底层细节。此处的变量包括 实例字段,静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后两者是线程私有的,不会被线程共享
Java内存模型规定了所有的变量都存储在主内存中。每个线程还有自己的工作内存,线程的工作内存中保存了该线程使用的变量是主线内存中的拷贝副本,线程对变量的所有操作「读取,赋值」都必须在工作内存进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对象工作内存中的变量,线程间变量值的传递均需要通过住内存来完成。
线程,主内存,工作内存三者直接关系
6.2 内存间交互操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存中拷贝到工作内存,如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定了如下 8种 操作来完成。
JVM是现实必须保证下面提及的 每一种操作都是原子的,不可再分的
- Lock「锁定」:作用于主内存的变量,它把一个变量标识为一个线程独占的状态
- Unlock「解锁」:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定
- Read「读取」:作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 Load 启动使用
- Load「载入」:作用于工作内存的变量,它把 Read 操作从主内存中得到的变量值放入工作内存的变量副本中
- Use「使用」:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎
- Assign「赋值」:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
- Store「存储」:作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便后续的 write 到左
- Write「写入」:作用于主内存的变量,它把 Store 操作从工作内存中得到的变量值放入主内存的变量中
Java内存模型的三大特性
- 原子性:由 Java 内存模型来直接保证的原子性变量操作包括:Read,Load,Assign,Store和Write。大致可以认为,基本数据类型的访问读写是具有原子性的。如若需要更大范围的原子性,需要 synchronized关键字约束。「即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么全部不执行」
- 可见性:当一个线程修改了共享变量的值,其它线程能够立即感知到这个变量的修改「volatile,synchronized,final三个关键字可以实现可见性」
- 有序性:如果在本线程内观察,所有的操作都是有序的「线程内表现为串行」;如果在线程中观察另外一个线程,所有的操作都是无需的「指令重排序 和 工作内存和主内存同步延迟」
Java内存模型具备一些先天的 有序性,却不需要通过任何手段就能够得到保证的有序性,这个通常也被称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么他们就不能保证他们的有序性,虚拟机可以随意的对它们进行重排序
happens-before「现行发生原则」
- 程序次序规则:一个线程哪,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个 Unlock 操作先行发生于后面对同一个锁的 Lock 操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作Byou先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作
- 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到终端时间的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束,Thread.isAlive() 的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于它的 finalize() 方法的开始
也就是说,要想并发程序正确执行,必须要保证原子性,可见性及有序性。只要有一个没有被保证,就有可能导致程序运行不正确
6.3 volatile 型变量的特殊规则
关键字 volatile 可以说是 JVM提供的最轻量级的同步机制,但是并不容易完全被正确理解和使用 JVM内存模型对 volatile 专门定义了一些特殊的访问规则
6.3.1 保证此变量对所有线程的可见性
这里的 可见性:当一个线程修改了这个变量的值,新值对于其它线程都是可以立即感知到的
而普通变量的做不到这一点,普通变量的值在线程间传递均需要通过主内存来完成。例如:线程A修改了一个普通变量的值,然后向主内存进行回写;另外一个线程B在线程A回写完成之后再从主内存进行读取操作,新值才会被B线程可见
误解:volatile变量在各个线程中是一致的,但是volatile变量的运算在并发下一样是不安全的「原因在于Java里面的运算并非原子操作」
public class Main {
public static volatile int num = 0;
public static void increase() {
num++;
}
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 100; j++) {
increase();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(num);
}
}
问题就在于 num++ 之中,实际上 num++ 等同于 num=num+1 。volatile 关键字保证了 num 的值在取值时是正确的,但是在执行 num+1 的时候其它线程可能已经把 num 值增大了,这样 +1 之后会把较小的数值同步回主内存之中。
由于 volatile 关键字之保证可见性,再不符合以下两条规则的运算场景中,我们仍然需要通过加锁「synchronized或者lock」来保证原子性
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
- 变量不需要与其它的状态变量共同参与不变约束
如下代码这类场景就特别适合使用volatile来控制并发,当shutdown()方法被调用时,能保证所有线程 中执行的doWork()方法都立即停下来。
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void work() {
while (!shutdownRequested) {
}
}
6.3.2 使用volatile变量的语义是禁止指令重排序
普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序和程序代码中执行的顺序一致。
volatile关键字禁止指令重排序有两层意思
- 当程序执行到 volatile 变量的读操作或写操作,在其前面的操作的更改肯定全部已经进行且结果已经对后面的操作可见;在其后面的操作肯定是没有进行
- 再进行指令优化时,不能将在对 volatile 变量的访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行
举例
x = 2;
y = 0;
flag = true;
x = 4;
y = -1;
由于 flag 变量为 volatile 变量,那么在进行指令重排序过程的时候,不会将于语句3放在语句1,语句2前面;也不会将语句3放到语句4,语句5的后面。但是要注意语句1和语句2的顺序,语句4和语句5的顺序是不可作任何保证的
并且 volatile 关键字能保证执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3,语句4,语句5是可见的
举例:指令重排序
Map configOptions;
char[] configText;
volatile boolean initialized = false;
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized =true;
while(!initialized){
sleep();
}
doSomethingWithConfig();
单例模式中的双重if检验
public class Singleton {
private static Singleton instance = null;
private Singleton() {
}
public static Singleton getSingleton() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为 双重检查锁,因为会有两次检查 instance == null ,一次是在同步块外,一次是在同步块内。为什么在 同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的 if,如果在同步块内不进行二次 检验的话就会生成多个实例了「生成多个实例时候会有更激烈的锁竞争」。
第一个if是为了判断是否需要实例化
第二个if是为了保证原子操作下实例化创建
其实还有一个弊端
问题在于 instance = new Singleton(); 这句,并非一个原子操作。事实上在 JVM 中这段代码做了下面3件事情
- 类加载,给 instance 分配内存
- 调用 Singleton 的构造函数来初始化成员变量
- 将 instance 对象指向分配的内存空间
执行完这3步,instance 就为非 null。但是在 JVM 编译器中存在指令重排序的优化操作,也就是说上面的第二第三操作的顺序是不能保证的,最终的执行顺序可能是 1-2-3也可能是1-3-2 如果是后者,则在 3 执行完毕,2未执行之前,被其它线程抢占了,这时 instance 已经是 非null 了「但却没有初始化」,所以其它线程直接返回 instance 然后使用,也就顺理成章的爆出空指针异常。
我们只需要用 volatile 关键字修饰就好了
public class Singleton {
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getSingleton() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
|