[TOC]
JVM
上篇:内存与垃圾回收
1、JVM与Java体系结构
1、关于Java与JVM
Java:跨平台的语言
JVM:跨语言的平台
2、字节码
随着JDK7.0的正式发布,JVM平台上运行非Java语言编写的程序。
JVM根本不关心运行在其内部的程序到底是使用何种编程语言编写的,它只关心“字节码”文件。也就是说Java虚拟机拥有语言无关性,并不会单纯地与Java语言“终身绑定”,只要其他编程语言的编译结果满足并包含JVM的内部指令集、符号表以及其他的辅助信息,它就是一个有效的字节码文件,就能够被虚拟机所识别并装载运行。
我们平时说的java字节码,指的是用java语言编译成的字节码。准确的说任何能在jvm平台上执行的字节码格式都是一样的。所以应该统称为:jvm字节码。
不同的编译器,可以编译出相同的字节码文件,字节码文件也可以在不同的JVM上运行。
Java虚拟机与Java语言并没有必然的联系,它只与特定的二进制文件格式——Class文件格式所关联, Class文件中包含了JVM指令集(或者称为字节码、Bytecodes)和符号表,还有一些其他辅助信息。
3、多语言混合编程
Java平台上的多语言混合编程正成为主流,通过特定领域的语言去解决特定领域的问题是当前软件开发应对日趋复杂的项目需求的一个方向。
例如:在一个项目之中,并行处理用Clojure语言编写,展示层使用JRuby/Rails,中间层则是Java,每个应用层都将使用不同的编程语言来完成,而且,接口对每一层的开发者都是透明的,各种语言之间的交互不存在任何困难,就像使用自己语言的原生API一样方便,因为它们最终都运行在一个虚拟机之上。
对这些运行于Java虚拟机之上、Java之外的语言,来自系统级的、底层的支持正在迅速增强,以JSR-292为核心的一系列项目和功能改进(如DaVinci Machine项目、 Nashorn引擎、InvokeDynamic指令、java. lang. invoke包等),推动Java虚拟机从“Java语言的虚拟机”向“多语言虚拟机”的方向发展。
4、虚拟机与JVM(java虚拟机)
1、什么是虚拟机
所谓虚拟机(Virtual Machine),就是一台虚拟的计算机,它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机。
- 大名鼎鼎的Visual Box, VMware就属于系统虚拟机,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台。
- 程序虚拟机的典型代表就是Java虚拟机,它专门为执行单个计算机程序而设计,在Java虛拟机中执行的指令我们称为Java字节码指令。
但无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。
2、JVM(java虚拟机)
JVM是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成。
JVM平台的各种语言可以共享JVM带来的跨平台性、优秀的垃圾回器,以及可靠的即时编译器。
Java技术的核心就是Java虚拟机(JVM,Java Virtual Machine) ,因为所有的Java程序都运行在JVM内部。
3、JVM的作用
JVM就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条Java指令Java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。
4、JVM的特点
- 一次编译,到处运行
- 自动内存管理
- 自动垃圾回收功能
带来的好处:
- 从代码层面:降低了内存泄漏与内存溢出的风险
- 从程序员层面:让程序员将自己对重心放在业务层面,不用再去手动地进行垃圾回收
带来的坏处:
- 降低了对程序员对要求,当出现内存方面的问题时不能有效解决。
5、JVM的位置
JVM是运行在操作系统之上的,它与硬件没有直接的交互。
JVM模拟的是系统,在不同系统之上,构建了一个统一的系统平台。所以在安装JDK的时候要关注JDK是安装在哪个操作系统上,因为不同的操作系统上安装的JVM是不同的。
JDK的构成:下图来自JDK8官网。
java程序想要正确运行需要经历两个过程:
- java文件 –(编译)–> class字节码文件:使用的编译器为:前端编译器。典型:javac
- class字节码文件–(解释)–> 二进制文件:运行。解释会用到:Java SE API 还有后端编译器(将class字节码文件编译为二进制文件)(后端编译器在JVM当中)
5、JVM的整体结构
HotSpotVM是目前市面上高性能虚拟机的代表作之一。
它采用解释器与即时编译器并存的架构。
在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C+ +程序一较高下的地步。
JVM的架构简图:程序的解释运行图
其中将其分成三层:
上层:class字节码文件进入类装载器子系统(Class loader),将class字节码文件加载到内存当中,生成一个大的class对象。这个过程中会涉及到:
- 加载
- 链接(分成三步)
- 初始化
中层:
- 方法区和栈是多线程共享
- (Java栈(本地方法栈),本地方法栈,程序计数器是每个线程独有一份
下层:把字节码文件加载到内存以后,就可以进行解释运行了。执行引擎(Execution Engine),有三部分内容:
解释器(Interpreter):负责字节码文件的解释运行。主要保证程序执行的响应时间
及时编译器(JIT Compiler):对于反复运行的热点代码进行提前的编译缓存。及时编译器又叫做后端编译器,用来将字节码文件字节码指令编译成操作系统能读懂的机器指令。(高级语言->机器语言)主要负责程序的执行性能。
垃圾回收器(Garbage Collection,简称GC):实现垃圾的自动回收
6、Java代码的执行流程
7、JVM的架构模型
Java编译器输入的指令流基本上分为两种:
- 是一种基于栈的指令集架构
- 另外一种指令集架构则是基于寄存器的指令集架构。
两种架构的区别:
- 基于栈式架构的特点:
- 设计和实现更简单,适用于资源受限的系统;
- 避开了寄存器的分配难题:使用零地址指令方式分配;
- 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现。
- 不需要硬件支持,可移植性更好,更好实现跨平台
- 基于寄存器架构的特点
- 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虛拟机;
- 指令集架构则完全依赖硬件,可移植性差
- 性能优秀和执行更高效;
- 花费更少的指令去完成一项操作。
- 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主。
总结:
- 由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。
- 优点是跨平台, 指令集小,编译器容易实现
- 缺点是性能下降,实现同样的功能需要更多的指令
- 时至今日,尽管嵌入式平台已经不是Java程序的主流运行平台了(准确来说应该是HotSpotVM的宿主环境已经不局限于嵌入式平台了),那么为什么不将架构更换为基于寄存器的架构呢?
- 基于栈式的架构在设计与实现上比基于寄存器架构的设计要简单
- 基于栈式的架构在非资源受限的场景当中也是可以使用的
- 主要还是因为栈式架构可以实现跨平台,而基于寄存器架构由于与硬件的耦合度太高,不能实现跨平台。
8、JVM的生命周期
1、虚拟机的启动
Java虛拟机的启动是通过引导类加载器(bootstrap class loader) 创建一个初始类(initial class) 来完成的,这个类是由虚拟机的具体实现指定的。
程序的执行:主方法(main)所在类加载到内存当中。而自定义的类的加载是通过系统类加载器(应用类加载器)实现的。由于父类的加载要早于子类,这就导致了java虚拟机的启动,创建一个初始类(initial class) ,然后调用初始类(initial class)当中的main方法,在这main方法当中使用其他的一些类来相继地加载后继的所有类。
类加载器分成:
- 引导类加载器(负责超类的加载(如Object))
- 扩展类加载器
- 系统类加载器(负责自定义类的类加载)
- 启动类加载器
- 用户自定义的类加载器
2、虚拟机的执行
- 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序。
- 程序开始执行时他才运行,程序结束时他就停止。
- 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虛拟机的进程。
3、虚拟机的退出
有如下的几种情况:
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虛拟机进程终止
- 某线程调用Runtime类或System类的exit方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作。
- 除此之外,JNI ( Java Native Interface) 规范描述了用JNI Invocation API来加载或卸载Java虚拟机时,Java虚拟机的退出情况。
9、JVM的发展历程
1、Sun Classic VM(SUN)
Sun公司发布的世界上第一款商用Java虚拟机,在JDK1.4时被完全淘汰。
这款虚拟机内部只提供解释器。
如果使用JIT编译器,就需要进行外挂。但是一旦使用了JIT编译器,JIT就会接管虚拟机的执行系统。解释器就不再工作。解释器和编译器不能配合工作。(两者只能存一)
- 只使用解释器:当代码中重复的代码多(如循环等等)的时候执行效率低
- 只使用JIT编译器:由于将字节码文件当中字节码指令编译成机器指令进行缓存也是需要时间的。这就导致了程序启动时间过长,加上占用的缓存空间有限。
现在hotspot内置了此虚拟机。
2、Exact VM(SUN)
为了解决上一个虚拟机问题,JDK1.2时, sun提供了此虚拟机。
Exact Memory Management:准确式内存管理
- 也可以叫Non-Conservative/ Accurate Memory Management
- 虚拟机可以知道内存中某个位置的数据具体是什么类型。
具备现代高性能虚拟机的雏形
- 热点探测
- 编译器与解释器混合工作模式
只在Solaris平台短暂使用,其他平台上还是classic vm。最终被Hotspot虚拟机替代
3、Hotspot虚拟机(三大虚拟机之一)(Longview Technologies\SUN\Oracle)
JDK1.3时,HotSpot VM成为默认虚拟机
目前Hotspot占有绝对的市场地位:
- 不管是现在仍在广泛使用的JDK6,还是使用比例较多的JDK8中,默认的虚拟机都是
HotSpot - Sun/Oracle JDK和OpenJDK的默认虚拟机
- 因此本课程中默认介绍的虛拟机都是HotSpot,相关机制也主要是指HotSpot的GC机
制。(比如其他两个商用虚拟机都没有方法区的概念)
从服务器、桌面到移动端、嵌入式都有应用。
名称中的HotSpot指的就是它的热点代码探测技术。
- 通过计数器找到最具编译价值代码,触发即时编译或栈上替换
- 通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡
4、JRockit(三大虚拟机之一)(BEA\Oracle)
专注于服务器端应用
- 它可以不太关注程序启动速度,因此JRockit内部不包含解析器实现,全部代码都靠即时编译器编译后执行。
大量的行业基准测试显示,JRockit JVM是 世界上最快的JVM。
- 使用JRockit产品,客户已经体验到了显著的性能提高(一些超过了70号)和硬件成本的减少(达50号)
优势:全面的Java运行时解决方案组合
- JRockit面向延迟敏感型应用的解决方案:JRockit Real Time提供以毫秒或微秒级的JVM响应时间,适合财务、军事指挥、电信网络的需要
- MissionControl服务套件:它是一组以极低的开销来监控、管理和分析生产
环境中的应用程序的工具。- JDK Mission Control(JMC)(Oracle公司整合)(主要是用来监控内存泄漏)
- 内存泄漏监测器
- JVM运行时分析器
- 管理的控制台
- JDK Mission Control(JMC)(Oracle公司整合)(主要是用来监控内存泄漏)
2008年,BEA被Oracle收购。
Oracle表达了整合两大优秀虚拟机的工作,大致在JDK 8中完成。整合的方式是在HotSpot的基础上,移植JRockit的优秀特性。
5、J9(三大虚拟机之一)(IBM)
全称: IBM Technology for Java Virtual Machine, 简称IT4J,内部代号: J9
市场定位与HotSpot接近,服务器端、桌面应用、嵌入式等多用途VM,广泛用于IBM的各种Java产品。
目前,有影响力的三大商用服务器之一,也号称是世界上最快的Java虚拟机(在使用自己家产品时)。
2017年左右,IBM发布了开源J9 VM,命名为openJ9,交给Eclipse基金会管理,也称为Ecilpse OpenJ9
6、KVM和CDC/ CLDC Hotspot
Oracle在Java ME产品线上的两款虚拟机为: CDC/CLDC HotSpot Implementation VM
KVM (Kilobyte)是CLDC- HI早期产品
目前移动领域地位尴尬,智能手机被Android和iOS二分天下。
KVM简单、轻量、高度可移植,而向更低端的设备上还维持自己的一片市场
- 智能控制器、传感器
- 老人手机、经济欠发达地区的功能手机
所有的虚拟机的原则:一次编译,到处运行。
7、Azul VM(Azul Systems)
前而三大“高性能Java虚拟机”使用在通用硬件平台上
这里Azul VM和BEA Liquid VM是与特定硬件平台绑定、软硬件配合的专有虚拟机(高性能Java虚拟机中的战斗机)
Azul VM是Azul Systems 公司在HotSpot基础上进行大量改进,运行于Azul Systems 公司的专有硬件Vega系统上的Java虚拟机。
每个Azul VM实例都可以管理至少数十个CPU和数百GB内存的硬件资源,并提供在巨大内存范围内实现可控的GC时间的垃圾收集器、专有硬件优化的线
程调度等优秀特性。
2010年,Azul Systems 公司开始从硬件转向软件,发布了自己的Zing JVM,可以在通用x86平台上提供接近于Vega系统的特性。
8、Liquid VM(BEA)
高性能Java虚拟机中的战斗机
BEA公司开发的,直接运行在自家Hypervisor系统上
Liquid VM即是现在的JRockit VM(Virtual Edition),Liquid VM不需要成操作系统的支持,或者说它自己本身实现了一个专用操作系统的必要功能,如线程调度、文件系统、网络支持等。
随着JRockit虚拟机终止开发,Liquid VM项目也停止了。
9、Apache Harmony(IBM和Inter)
Apache Harmony是IBM和Inter联合开发的开源JVM,受到同样开源的OpenJDK的压制。
虽然目前并没有Apache Harmony被大规模商用的案例,但是它的Java类库代码吸纳进了Android SDK。
10、Microsoft JVM(Microsoft)
微软为了在IE3浏览器中支持Java Applets,开发了Microsoft JVM。
只能在Window平台下运行。但确实是当时Windows下性能最好的Java VM。
1997年,Sun以侵犯商标、不正当竞争罪名指控微软成功,赔了sun很多钱。微软在windowsXP SP3中抹掉了其VM。现在windows上安装的jdk都是HotSpot。
11、TaobaoJVM(Alibaba)
Alibaba基于OpenJDK开发了自己的定制版本AlibabaJDK,简称AJDK。是整个阿里Java体系的基石。
基于OpenJDK HotSpot VM发布的国内第一个优化、深度定制且开源的高性能服务器版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 NM应用在阿里产品上性能高,硬件严重依赖Intel的CPU,损失了兼容性,但提高 了性能。
目前已经在淘宝、天猫上线,把Oracle官方版本全部替换了。
12、Dalvik VM(Google)
谷歌开发的,应用与Android系统,并在Android2.2中提供了JIT,发展迅猛。
Dalvik VM只能称作虚拟机,而不能称作”Java 虚拟机”,它没有遵循Java虚拟机规范
不能执行Java的Class文件
基于寄存器架构,不是jvm的栈架构
执行的是编译以后的dex(Dalvik Executable)文件。执行效率比较高。
- 它执行的dex(Dalvik Executable)文件可以通过Class文件转化而来,使用Java语法编写的应用程序,可以直接使用大部分的Java API等。
Android5.0使用支持提前编译(Ahead of Time Compila,AOT)的ART VM替换了Dalvik VM
13、Graal VM
2018年4月,Oracle Labs公开了Graal VM,号称”Run Programs Faster Anywhere“。与1995年java的”write once,run anywhere”遥相呼应。
Graal VM在HotSpot VM基础上增强而成的跨语言全栈虚拟机,可以作为”任何语言”的运行平台使用。语言包括:Java、Scala、Groovy、Kotlin;C、C++、JavaScript、Ruby、Python、R等。
支持不同语言中混合对方的接口和对象,支持这些语言使用已经编写好的本地库文件
工作原理:将这些语言的源代码或源代码编译后的中间格式,通过解释器转换成能被Graal VM接收的中间表示。Graal VM通过Truffle工具快速构建面向一种新语言的解释器。在运行时还能进行即时编译优化,获得比原生编译器更优秀的执行效率。
如果说HotSpot有一天真的被取代,Graal VM希望最大。但是Java的软件生态没有丝毫变化。
14、其他虚拟机
- Java Card VM
- Squawk VM
- JavaInJava
- Maxine VM
- Jikes RVM
- IKVM.NET
- Jam VM
- Cacao VM
- Sable VM
- Kaffe
- Jelatine JVM
- Nano VM
- MRP
- Moxie JVM
2、类加载器子系统(Class Loader)
1、内部结构概述
1、类加载器子系统作用
- 类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识(CA FE BA BE,是一个魔数(Coffee baby))。
- ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
- 加载的类信息存放与一块称为方法区的内存空间。除了类信息以外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)常量池在运行过程中加载到内存里,叫运行时常量池。
2、类加载器ClassLoader角色
- class file 存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来,根据这个文件实例化出n个一模一样的实例。
- class file 加载到JVM中,被称为DNA元数据模板,放在方法区。
- 在.class文件-> JVM ->最终成为元数据模板,此过程就要一个运输工具(类装载器Class Loader) ,扮演一个快递员的角色。
2、类加载器与类的加载过程
1、JVM架构
JVM架构-简图:
JVM架构-详细图解(中英文):
2、类加载的过程
类加载的过程:
程序加载过程:
3、类加载的三个阶段
1、阶段一:Loading(加载)
加载:
- 通过一个类的全限定类名获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java. lang.Class对象, 作为方法区这个类的各种数据的访问入口。
补充:加载.class文件的方式:
- 从本地系统中直接加载
- 通过网络获取,典型场景: Web Applet
- 从zip压缩包中读取,成为日后jar、war格式的基础
- 运行时计算生成,使用最多的是:动态代理技术(java.lang.reflect.proxy)
- 由其他文件生成,典型场景:JSP应用
- 从专有数据库中提取.class文件,比较少见
- 从加密文件中获取,典型的防Class文件被反编译的保护措施
2、阶段二:Linking(链接)
验证(Verify) :
目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
主要包括四种验证
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
例如:class文件在文件开头有特定的文件标识(CA FE BA BE,是一个魔数(Coffee baby))
准备(Prepare) :
- 为类变量分配内存并且设置该类变量的默认初始值,即零值。
- 这里不包含用final修饰的static(即:常量),因为final在编译的时候就会分配了,准备阶段会显式初始化。
- 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
例如:
1 | //prepare: a = 0 ---> Initialization : a = 1 |
其中数据类型不同,默认初始值也就不同:
- 整型(byte\short\int\long):0
- 浮点型(float\double):0.0f
- 字符型(char):\u0000
- 布尔型(boolean):false
- 引用类型:null
解析(Resolve) :
- 将常量池内的符号引用转换为直接引用的过程。
- 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。
- 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
- 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_ Class_ info、CONSTANT Fieldref_ info、 CONSTANT Methodref_ info等。
3、阶段三:Initialization(初始化)
初始化:
初始化阶段就是执行类构造器方法
()的过程 。此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
当不涉及到类变量的赋值动作与有关静态static(包括静态代码快、静态构造器,静态变量等等)的动作时,类构造器方法
()不会创建。 1
2
3
4
5
6
7public class ClinitTest {
//任何一个类声明以后,内部至少存在一个类的构造器<init>
private int a = 1;
public static void main(String[] args) {
int b = 2;
}
}构造器方法中指令按语句在源文件中出现的顺序执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class ClassInitTest {
private static int num = 1;
static{
num = 2;
// 赋值
number = 20;
System.out.println(num);
//System.out.println(number);//报错:非法的前向引用。
}
// 声明
private static int number = 10; //linking之prepare: number = 0 --> initial: 20 --> 10
public static void main(String[] args) {
System.out.println(ClassInitTest.num);//2
System.out.println(ClassInitTest.number);//10
}
}()不同于类的构造器 。(关联: 构造器是虚拟机视角下的()) 任何一个类声明以后,内部至少存在一个类的构造器
(可以是自己声明的,也可以说系统默认提供的) 若该类具有父类,JVM会保证子类的
()执行前,父类的 。()已经执行完毕 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public class ClinitTest1 {
static class Father{
public static int A = 1;
static{
A = 2;
}
}
static class Son extends Father{
public static int B = A;
}
public static void main(String[] args) {
//加载Father类,其次加载Son类。
System.out.println(Son.B);//2
}
}虚拟机必须保证一个类的
()方法在多线程下被同步加锁 。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public class DeadThreadTest {
public static void main(String[] args) {
Runnable r = () -> {
System.out.println(Thread.currentThread().getName() + "开始");
DeadThread dead = new DeadThread();
System.out.println(Thread.currentThread().getName() + "结束");
};
Thread t1 = new Thread(r,"线程1");
Thread t2 = new Thread(r,"线程2");
t1.start();
t2.start();
}
}
class DeadThread{
static{
if(true){
// 若一个类的<clinit>()方法在多线程下被同步加锁
// 那么这里的打印代码就只会执行一次
System.out.println(Thread.currentThread().getName() + "初始化当前类");
while(true){
}
}
}
}执行结果:
线程2开始
线程1开始
线程2初始化当前类一个类只需要往内存中加载一次就可以了,加载之后将其放在方法区(方法区在JDK7之前被称为永久代,JDK7之后被称为源空间),源空间其实使用的是本地内存,即类加载到内存之后是使用直接内存进行缓存。若以后使用到该类,那么使用的都是内存中已经存在的类本身。所以,虚拟机在加载类的时候只会调用一次
方法 。
3、类加载器分类
1、类加载器分类
JVM支持两种类型的类加载器,分别为引导类加载器( Bootstrap ClassLoader)**和自定义类加载器(User-Defined ClassLoader)** 。
引导类加载器( Bootstrap ClassLoader):
- 本身不是使用java语言编写,而是使用C与C++进行编写
自定义类加载器(User-Defined ClassLoader)
使用java语言编写
派生于抽象类ClassLoader。所以扩展类加载器(Extinction Class Loader)与系统类加载器(System Class Loader)都属于自定义类加载器
其中sun.misc.Launcher它是一个java虚拟机的入口应用
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。
无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个:
这里的四者之间的关系是包含关系。不是上层下层,也不是子父类的继承关系。
对于引导类加载器(Bootstrap Class Loader)、扩展类加载器(Extension Class Loader)与系统类加载器(System Class Loader)三者的关系:
- 系统类加载器(System Class Loader)的上层就是扩展类加载器(Extension Class Loader):对于用户自定义类来说:默认使用系统类加载器进行加载
- 扩展类加载器(Extension Class Loader)的上层是引导类加载器(Bootstrap Class Loader)
- 引导类加载器(Bootstrap Class Loader)是最高层的类加载器:Java的核心类库都是使用引导类加载器进行加载的。并且我们获取不到引导类加载器。因为引导类加载器并不是所以java语言进行编写的。
1 | public class ClassLoaderTest { |
2、启动类加载器(引导类加载器,Bootstrap ClassLoader )(虚拟机自带的加载器)
- 这个类加载使用C/C++语言实现的,嵌套在JVM内部,是JVM的一部分。
- 它用来加载Java的核心库(JAVA HOME/jre/lib/rt.jar、resources. jar或sun.boot.class.path路径下的内容) , 用于提供JVM自身需要的类
- 并不继承自java. lang .ClassLoader,没有父加载器。
- 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
- 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
3、扩展类加载器(Extension ClassLoader)(虚拟机自带的加载器)
Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
派生于ClassLoader类
父类加载器为启动类加载器
从java. ext. dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。
如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
JDK9以后扩展类加载器改为平台类加载器
4、应用程序类加载器(系统类加载器,AppClassLoader)(虚拟机自带的加载器)
- java语言编写,由sun.misc.Launcher$AppClassLoader实现
- 派生于ClassLoader类
- 父类加载器为扩展类加载器
- 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
- 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
- 通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器
5、用户自定义类加载器
在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。
为什么要自定义类加载器:
隔离加载类:
在某些框架中需要使用中间件,然而中间件与应用模块是隔离的。所以需要把类加载到不同的环境当中,确保应用当中引用到的框架的jar包与中间件的jar包是不冲突的(冲突:框架与中间件的某些类的类名一样,路径也相同)。
所以需要做一个类的仲裁。一般主流的容器类框架都会自定义类加载器,让本身与不同中间件之间是隔离的,避免类的冲突。
修改类加载的方式:
除了引导类加载器,其他类加载器都可以在需要的时候进行动态加载
扩展加载源:
可以从数据库当中,或者电视机的机饼盒等等加载字节码文件的来源
防止源码泄漏:
对字节码文件进行加密,防止被反编译篡改。
加密之后运行代码时就需要进行解密,这时候就可以通过自定义加载器的方式进行解密
用户自定义类加载器实现步骤:
开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求;
在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中。(findClass()方法与defineClass()方法配合使用)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31public class CustomClassLoader extends ClassLoader {
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] result = getClassFromCustomPath(name);
if(result == null){
throw new FileNotFoundException();
}else{
return defineClass(name,result,0,result.length);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
}
throw new ClassNotFoundException(name);
}
private byte[] getClassFromCustomPath(String name){
//从自定义路径中加载指定类:细节略
//如果指定路径的字节码文件进行了加密,则需要在此方法中进行解密操作。
return null;
}
public static void main(String[] args) {
CustomClassLoader customClassLoader = new CustomClassLoader();
try {
Class<?> clazz = Class.forName("One",true,customClassLoader);
Object obj = clazz.newInstance();
System.out.println(obj.getClass().getClassLoader());
} catch (Exception e) {
e.printStackTrace();
}
}
}在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
4、Class Loader的使用说明
ClassLoader类,它是一个抽象类,其后所有的类加载器都继承自ClassLoader (不包括启动类加载器)
相关方法与描述:
方法名称 | 方法名称 |
---|---|
getParent() | 返回该类加载器的超类加载器 |
loadClass(String name) | 加载名称为name的类,返回结果为java.lang.Class类的实例 |
findClass(String name) | 查找名称为name的类,返回结果为java lang Class类的实例 |
findLoadedClass(String name) | 查找名称为name的已经被加载过的类,返回结果为java lang Class类的实例 |
defineClass(String name, byte[] b, int off, int len) | 把字节数组b中的内容转换为一个Java类,返回结果为java.lang.Class类的实例 |
resolveClass(Class<?> c) | 连接指定的一个Java类 |
获取ClassLoader的途径:
获取当前类的ClassLoader
1
clazz.getClassLoader()
获取当前线程上下文的ClassLoader
1
Thread.currentThread().getContextClassLoader()
获取系统的ClassLoader
1
ClassLoader.getSystemClassLoader()
获取调用者的ClassLoader
1
DriverManager.getCallerClassLoader()
5、双亲委派机制
1、什么是双亲委派机制
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。
2、工作原理
- 如果一个类加载器收到类加载请求,它并不会自己去加载,而是把这个请求委托给父类的加载器去执行;
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
3、例子
- SPI接口是由引导类加载器加载的
- 具体接口的实现类由于使用了第三方jdbc.jar,所以是由线程上下文类加载器加载的,而线程上下文类加载器的默认就是系统类加载器。(反向委派)
4、优势
避免类的重复加载
保护程序安全,防止核心API被随意篡改
- 自定义类:java.lang.String
- 自定义类:java.lang,ShkStart
在src目录下新建java.lang.String:
1 | package java.lang; |
运行结果:
在类 java.lang.String 中找不到 main 方法
在src目录下新建java.lang并在该包下编写自定义的类:
1 | public class ShkStart { |
运行结果:java.lang.SecurityException:Prohibited package name:java.lang
沙箱安全机制:
自定义String类,但是在加载自定义String类的时候会率先随意引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java\lang\String.class),报错信息说没有main方法就是因为加载的是rt.jar包中的String类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制。
6、其他
1、在JVM中表示两个class对象是否为同一个类存在两个必要条件
- 类的完整类名必须一致,包括包名。
- 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同
换句话说:在JVM中,既使这两个类对象(class对象)来源于同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的。
2、对类加载器的引用
JVM必须知道一个类型是由启动类加载器加载的还是有用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。
3、类的主动使用与被动使用
Java程序对类的使用方式分为:主动使用和被动使用
主动使用,又分为七种情况:
创建类的实例
访问某个类或接口的静态变量,或者对该静态变量赋值
调用类的静态方法
反射(比如:Class.forName(“com.atguigu.Test”))
初始化一个类的子类
Java虚拟机启动时被标明为启动类的类
JDK7开始通过动态语言支持:
java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化
被动使用:除了上述七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化。
3、运行时数据区概述及线程
1、概述
1、经典的JVM内存布局
内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。
不同的JVM对于内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,来探讨一下经典的JVM内存布局:
其中:方法区在JVM规范中是一个逻辑概念,由虚拟机自己进行具体实现。
- HotSpot7和以前的版本用的是堆上的永久代实现方法区
- HotSpot8之后使用元数据区实现方法区
- 常量池在jdk8以后也被放到了堆中
2、进程同步与线程同步:
Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁(进程同步)**。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁**。
灰色的为单独线程私有的,红色的为多个线程共享的。即:
- 每个线程:独立包括程序计数器、栈、本地栈。
- 每个进程:线程间共享,堆、堆外内存(永久代或元空间、代码缓存)(问题:怎么保证线程安全)
其中:(一个线程一份)
- PC:程序计数器
- VMS:虚拟机栈
- NMS:本地方法栈
3、关于线程间共享的说明:
每个JVM只有一个Runtime实例。即为运行时环境,相当于内存结构的中间的那个框框(运行时数据区(Runtime Data Area)):运行时环境。
2、线程
1、关于线程
线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行。
在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射。
- 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。
操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。
2、线程的分类
- 普通线程
- 守护线程
3、JVM的系统线程
如果你使用jconsole或者是任何一个调试工具,都能看到在后台有许多线程在运行。
这些后台线程不包括调用public static void main (String[])的main线程以及所有这个main线程自己创建的线程。
这些主要的后台系统线程在HotSpot JVM里主要是以下几个:
虚拟机线程:
这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括**”stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销**。
周期任务线程:
这种线程是时间周期事件的体现(比如中断)**,他们一般用于周期性操作的调度执行**。
GC线程:
这种线程对在JVM里不同种类的垃圾收集行为提供了支持。
编译线程:
这种线程在运行时会将字节码编译成到本地代码。
信号调度线程:
这种线程接收信号并发送给JVM, 在它内部通过调用适当的方法进行处理。
4、程序计数器(PC寄存器)
1、PC Register介绍
JVM中的程序计数寄存器(Program Counter Register) 中,Register 的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。
这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。
作用:
PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。
- 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。
- 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefned)
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
- 它不存在垃圾回收问题。
- 它是唯一一个在Java虚拟机规范中没有规定任何OutOtMemoryError情况的区域。
- 即:无GC,无OOM
2、举例说明
针对5
进行举例:(PC寄存器的意义或者作用)
- 指令地址
5
就是PC寄存器里面存放的值 - 执行引擎会在PC寄存器里面获取指令地址对应的操作指令(istore_2)
- 执行引擎得到操作指令后会执行下面两个操作:
- 操作虚拟机栈(如局部变量表、操作数栈等等),实现数据的存取操作以及一些求和运算等等。
- 把字节码指令翻译为机器指令
- 机器指令可以让对应的CPU做运算
3、两个常见问题
使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?
因为CPU需要不停的切换各个进程,这时候切换回来以后,就得知道从哪开始继续执行。
JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
PC寄存器为什么会被设定为线程私有?
我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?
为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。
由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。
这样必然导致经常中断或恢复,如何保证分毫无差呢?
每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
4、CPU时间片
CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。
在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。
但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平?一种方法就是引入时间片,每个程序轮流执行。
并行与并发:
- 并行就是两个核同时算
- 并发就是一个核算两个一人一段。
5、虚拟机栈
1、虚拟机栈概述
1、虚拟机栈出现的背景
由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。
优点:
- 跨平台
- 指令集小
- 编译器容易实现
缺点:
- 性能下降
- 实现同样的功能需要更多的指令
2、内存中的栈与堆
栈:
- 栈是运行时的单位
- 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据
堆:
- 堆是存储的单位
- 堆解决的是数据存储的问题,即数据怎么放、放在哪里。
3、虚拟机栈基本内容
Java虚拟机栈是什么?
Java虚拟机栈(Java Virtual Machine Stack) ,早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame) (栈存储数据的基本单位),对应着一次次的Java方法调用。
- 是线程私有的
Java虚拟机栈生命周期:
- 生命周期和线程一致。
Java虚拟机栈作用:
- 主管Java程序的运行,它保存方法的**局部变量(8种基本数据类型、对象的引用)**、部分结果,并参与方法的调用和返回。
栈的特点(优点):
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
JVM直接对Java栈的操作只有两个:
- 每个方法执行,伴随着进栈(入栈、压栈)
- 执行结束后的出栈工作
对于栈来说不存在垃圾回收问题,但是存在内存溢出的情况。
即:无GC,有OOM
栈中可能存在的异常:
Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常。
如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常。
设置栈内存大小:
我们可以使用参数-Xss选项来设置线程的最大栈空间,**栈的大小直接决定了函数调用的最大可达深度
**。
设置步骤:
在IDEA点开
Run
在Run下面有选项
Edit Configurations...
在当前类下的
VM options
中进行参数设置。(参数参考上-Xss的设置)程序调优的一种方案:参数调优
参数设置后的测试方法:
2、栈的存储单位
1、栈中存储什么?
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)为基本存储单位的格式存在。
- 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
2、栈的运行原理
JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则。
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈项栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame) ,与当前栈帧相对应的方法就是当前方法(Current Method)**,定义这个方法的类就是当前类(Current Class)** 。
执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
Java方法有两种返回函数的方式:
- 正常的函数返回,使用return指令
- 抛出异常
不管使用哪种方式,都会导致栈帧被弹出。
3、栈帧的内部结构
每个栈帧中存储着:
- 局部变量表(Local Variables:LV)
- 操作数栈(Operand Stack) (或表达式栈)
- 动态链接(Dynamic Linking) ( 或指向运行时常量池的方法引用)
- 方法返回地址(Return Address) (或方法正常退出或者异常退出的定义)
- 一些附加信息
3、局部变量表( Local Variables)
1、局部变量表的概述
- 局部变量表也被称之为局部变量数组或本地变量表
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference) ,以及
return Address类型。(因为各类数据类型都可以通过数字来表示) - 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
- 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。 在方法运行期间是不会改变局部变量表的大小的。
- 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
- 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
2、对程序编译后的字节码文件的查看方法
程序代码:(以main方法为例,其他方法类似)
1 | public class LocalVariablesTest { |
3、关于Slot的理解
参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。
局部变量表的最基本的存储单元是Slot (变量槽)
局部变量:表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
在局部变量表里,32位以内的类型只占用一个slot (包括returnAddress类型),64位的类型(long和double)占用两个slot。
- byte、short 、char、在存储前被转换为int,boolean 也被转换为int,0表示false,非0表示true,float、引用数据类型的引用
- long和double则占据两个Slot。
JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的基本变量值
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上
如果需要访问局部变量表中一个64bit的基本变量值时,只需要使用前一个索引即可。(比如:访问long或double类型变量)
如果当前帧是由构造方法或者实例方法创建的,那么该
对象引用this
将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。在静态(static)方法中不能引用this:因为this变量不存在于静态方法的局部变量表中!!
4、Slot的重复利用
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
变量c使用之前已经销毁的变量b占据的slot的位置:
5、静态变量与局部变量的对比
变量的分类:
- 按照数据类型分:
- 基本数据类型
- 引用数据类型
- 按照在类中声明的位置分:
- 成员变量:在使用前,都经历过默认初始化赋值
- 类变量(static修饰): linking的prepare阶段:给类变量默认赋值 —> initial阶段:给类变量显式赋值即静态代码块赋值
- 实例变量(没有static修饰):随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
- 局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过。
- 成员变量:在使用前,都经历过默认初始化赋值
静态变量(类变量)与局部变量对比:
参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。
我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。
和类变量初始化不同的是,局部变量表**不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
1
2
3
4
5public void test(){
int i;
// 报错:没有赋值不能够使用。
System.out.println(i).
}
6、补充说明:
- 在栈帧中,与性能调优关系最为密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
- 局部变量表中的变量也是重要的
垃圾回收根节点
(根搜索算法\可达性分析),只要被局部变量表中直接或间接引用的对象都不会被回收。
4、操作数栈(Operand Stack)
每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last- In-First-Out:LIFO)的操作数栈,也可以称之为表达式栈(Expression Stack) 。
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push) /出栈(pop)。
某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。
比如:执行复制、交换、求和等操作
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量
临时
的存储空间。操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,在方法运行期间是不会改变操作数栈的大小的。保存在方法的
Code
属性中,为max_ stack
的值。栈中的任何一个元素都是可以任意的Java数据类型。
- 32bit的类型占用一个栈单位深度
- 64bit的类型占用两个栈单位深度
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push)和出栈(pop) 操作来完成一次数据访问。
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
5、代码跟踪
1、对操作数栈相关知识点的代码分析
2、面试问题:i++ VS ++i
1 | public void add(){ |
第一类问题:两种没什么区别,都是实现变量的加1操作
6、栈顶缓存技术
前面提过,基于栈式架构的虛拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派( instruction dispatch) 次数和内存读/写次数。
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(ToS,Top-of-stack Cashing) 技术,将栈顶元素全部缓存在物理CPU的寄存器
中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
7、动态链接(Dynamic Linking)(指向运行时常量池的方法引用)(帧数据区之一)
每一个栈帧内部都包含一个指向**
运行时常量池
中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)** 。比如: invokedynamic指令在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference) 保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。(多态也是通过动态链接实现的)
类被加载之后,Class文件中的常量池会被复制一份到方法区,成为“运行时常量池”
为什么需要常量池呢? 常量池的作用,就是为了提供一些符号和常量,便于指令的识别。
8、方法的调用:解析与分派
1、静态链接与动态链接
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。
静态链接:
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。动态链接:
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
2、方法的绑定机制
对应的方法的绑定机制为:早期绑定(Early Binding) 和晚期绑定(Late Binding) 。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
早期绑定:
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
晚期绑定:
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
3、虚方法与非虚方法
随着高级语言的横空出世,类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。
Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虛函数的特征时,则可以使用关键字final来标记这个方法。(通过final修饰不能重写)
子类对象的多态性的使用前提(多态 <–> 虚方法)
- 类的继承关系
- 方法的重写
非虚方法:
- 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。
- 静态方法、私有方法、final方法、实例构造器、父类方法(因为java没有多继承所以调用父类的方法是非虚方法:super.xxx()可以找到调用的是哪个方法)都是非虚方法。
- 其他方法称为虚方法。
虚拟机中提供了以下几条方法调用指令:
- 普通调用指令:
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用
方法、 私有及父类方法,解析阶段确定唯一方法版本 - invokevirtual:调用所有虛方法
- invokeinterface:调用接口方法
- 动态调用指令:
- invokedynamic: 动态解析出需要调用的方法,然后执行
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虛方法,其余的(final修饰的除外)称为虚方法。
4、关于invokedynamic
- JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现「动态类型语言」支持而做的一种改进。
- 但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式。
- Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器。
- 动态类型语言和静态类型语言:
- 动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者(编译期)就是静态类型语言,反之(运行期)是动态类型语言。
- 说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
5、方法重写的本质
Java语言中方法重写的本质:
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作
C
。 - 如果在类型
C
中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.illegalAccessError异常。 - 否则,按照继承关系从下往上依次对
C
的各个父类进行第2步的搜索和验证过程。 - 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
IllegalAccessError介绍:
程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。
6、虚方法表
在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table) (非虚方法不会出现在表中)来实现。使用索引表来代替查找。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
那么虚方法表什么时候被创建?
虚方法表会在类加载的链接阶段(解析Resolve)被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
每个类有一个虚方法表,使用某方法时直接在这表里查找该方法在哪个类里了。
没有虚方法表的情况下,需要在当前类查找,找不到再去父类查找。
9、方法返回地址(Return Address)
- 存放调用该方法的pc寄存器的值。
- 一个方法的结束,有两种方式:
- 正常执行完成
- 出现未处理的异常,非正常退出
- 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法:
执行引擎遇到任意一个方法返回的字节码指令(return) ,会有返回值传递给上层的方法调用者,简称正常完成出口;
- 一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。
- 在字节码指令中,返回指令包含
ireturn
(当返回值是boolean、 byte、char、short和int类型时使用)、lreturn
、freturn
、dreturn
以及areturn
,另外还有一个return
指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。
在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口。
方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
10、一些附加信息
栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。
11、栈的相关面试题
举例栈溢出的情况? (StackOverflowError)
- 通过-Xss设置栈的大小OOM
调整栈大小,就能保证不出现溢出吗?
- 不能
分配的栈内存越大越好吗?
- 不是!
垃圾回收是否会涉及到虚拟机栈?
- 不会的!
方法中定义的局部变量是否线程安全?
- 具体问题具体分析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49/**
* 面试题:
* 方法中定义的局部变量是否线程安全?具体情况具体分析
*
* 何为线程安全?
* 如果只有一个线程才可以操作此数据,则必是线程安全的。
* 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题。
* @author shkstart
* @create 2020 下午 7:48
*/
public class StringBuilderTest {
//s1的声明方式是线程安全的
public static void method1(){
//StringBuilder:线程不安全
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
//...
}
//sBuilder的操作过程:是线程不安全的
public static void method2(StringBuilder sBuilder){
sBuilder.append("a");
sBuilder.append("b");
//...
}
//s1的操作:是线程不安全的
public static StringBuilder method3(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1;
}
//s1的操作:是线程安全的
public static String method4(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1.toString();
}
public static void main(String[] args) {
StringBuilder s = new StringBuilder();
new Thread(() -> {
s.append("a");
s.append("b");
}).start();
method2(s);
}
}
12、关于运行时数据区的五大部分的OOM与GC问题
运行时数据区 | GC | OOM |
---|---|---|
程序计数器(PC寄存器) | × | × |
虚拟机栈 | × | √ |
本地方法栈 | × | √ |
堆 | √ | √ |
方法区 | √ | √ |
6、本地方法接口
1、什么是本地方法?
简单地讲,一个Native Method就是一个Java调用非Java代码的接口。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如C。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern “C”告知C++编译器去调用一个C的函数。
“A native method is a Java method whose implementation is provided by non-java code.”
在定义一个native method时, 并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java语言在外面实现的。
本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。
标识符native可以与所以其它的java标识符连用,但是abstract
除外。
1 | public class IHaveNatives { |
2、为什么要使用Native Method?
Java使用起来非常方便,然而有些层次的任务用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 SetPriority() API。 这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVM调用。
3、现状
目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等等,不多做介绍。
7、本地方法栈(Native Method Stack)
Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。
本地方法栈,也是线程私有的。
允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError异常。
- 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虛拟机将会抛出一个OutOfMemoryError异常。
本地方法是使用C语言实现的。
它的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库。
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
- 本地方法可以通过
本地方法接口
来访问虛拟机内部的运行时数据区。 - 它甚至可以直接使用本地处理器中的寄存器
- 直接从本地内存的堆中分配任意数量的内存。
- 本地方法可以通过
并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。(这里存在本地方法栈只是对于HotSpot JVM而言)
在HotSpot JVM中,直接将本地方法栈和虚拟机栈合二为一。
8、堆(Heap)
1、堆的核心概述
一个JVM实例只存在一个堆内存,堆也是Java
内存管理
的核心区域。Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。
- 堆内存的大小是可以调节的。
《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
这里涉及到对象实例在堆内存中的存储方式,物理内存连续的采用指针碰撞,不连续的采用动态链表
所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Al location Buffer, TLAB) 。
《Java虛拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。
- The heap is the run-time data area from which memory for all class instances and arrays is allocated.
- 我要说的是:“几乎”所有的对象实例都在这里分配内存。——从实际使用角度看的。
数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
堆,是GC ( Garbage Collection, 垃圾收集器)执行垃圾回收的重点区域。
内存细分:
现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:
- Java 7及之前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区
- Young Generation Space 新生区 Young/New
- 又被划分 为Eden区和Survivor区
- Tenure generation space 养老区 Old/Tenure
- Permanent Space 永久区 Perm
- Young Generation Space 新生区 Young/New
- Java 8及之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间
- Young Generation Space 新生区 Young/New
- 又被划分为Eden区和Survivor区
- Tenure generation space 养老区 Old/ Tenure
- Meta Space 元空间 Meta
- Young Generation Space 新生区 Young/New
约定:
- 新生区 <=> 新生代 <=> 年轻代
- 养老区 <=> 老年区 <=> 老年代
- 永久区 <=> 永久代
堆空间的内部结构:
- Java 7及之前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区
2、设置堆内存大小与OOM
- Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项”
-Xmx
“和”``-Xms`”来进行设置。- “
-Xms
“用于表示堆区(年轻代+老年代)的起始内存
,等价于-XX: InitialHeapSize- -X是JVM的运行参数
- ms是memory start
- “
-Xmx
”则用于表示堆区(年轻代+老年代)的最大内存
,等价于-XX :MaxHeapSize
- “
- 查看设置的参数:
- jps / jstat -gc 进程id
- -XX:+PrintGCDetails
- 一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutOfMemoryError异常。
- 开发中建议将初始堆内存和最大的堆内存设置成相同的值。即将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能,避免系统压力。
- 默认情况下,初始内存大小:物理电脑内存大小 / 64;最大内存大小:物理电脑内存大小 / 4
1 | /** |
结论:新生代的存储总量为一个伊甸园区加一个幸存者区(1或2,不能并存),所以虽然设置了600M,但是实际上为575M。
关于异常(Exception)与错误(Error):
Java 语言规范将派生于 Error 类或 RuntimeException 类的所有异常称为非检查型(unchecked)异常,所有其他的异常称为检查型(checked)异常
3、年轻代与老年代
存储在JVM中的Java对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。
Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen)
其中年轻代又可以划分为Eden空间、 Survivor0空间和Survivor1空间(有时也叫做from区、to区)
相关的参数设置与默认值(在开发中一般不会改变)
- -XX:NewRatio:配置新生代与老年代在堆结构的占比。
- 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
- 可以修改- XX: NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
- -XX:SurvivorRatio:设置新生代中Eden区与Survivor区的比例。默认值是8
- 在HotSpot中, Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1。
- 当然开发人员可以通过选项“-XX:SurvivorRatio””调整这个空间比例。比如-XX: SurvivorRatio=8
- 但在实际测试当中发现其实JVM分配Eden空间和两个Survivor空间的时候比例为6:1:1,即默认值为6而不是8,但是java官方表示的默认值就是8。如果要将比例修改为8:1:1,需要设置:
- -XX:-UseAdaptiveSizePolicy :关闭自适应的内存分配策略 (暂时用不到)(但发现没用,还是6:1:1)
- -XX: SurvivorRatio=8,手动设置SurvivorRatio为8。(这还算什么默认值。。。)
- -Xmn:设置新生代的空间的大小。(一般不设置)
- 但一般新生代与老年代的空间分配是-XX:NewRatio设置的,默认为2。
- 当设置了-Xmn的时候,就与-XX:NewRatio冲突了
- 这时候JVM使用的是-Xmn设置的值(JDK8下测试)
- -XX:NewRatio:配置新生代与老年代在堆结构的占比。
几乎所有
的Java对象都是在Eden区被new出来的。绝大部分的Java对象的销毁都在新生代进行了。
- IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。
可以使用选项”-Xmn”设置新生代最大内存大小。
- 这个参数一般使用默认值就可以了。
4、图解大小分配过程
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中停生内存碎片。
new的对象先放伊甸园区。此区有大小限制。
当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器**(Minor GC)将对伊甸园区进行垃圾回收**,将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。
然后将伊甸园中的剩余对象移动到幸存者0区(to区)。
如果再次触发垃圾回收,此时JVM的垃圾回收器(Minor GC)对伊甸园区进行垃圾回收(主动:伊甸园区满即触发),会放到幸存者1区(to区)。以及同时上次幸存下来的放到幸存者0区(from区)的,如果没有回收,也会放到幸存者1区(to区)。(被动:就算幸存者1区(to区)满了也不触发Minor GC垃圾回收器)
如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
啥时候能去养老区呢?可以设置次数。默认是
15
次。可以设置参数: -XX:MaxTenuringThreshold=
进行设置。 在养老区,相对悠闲。当养老区内存不足时,再次触发GC: Major GC, 进行养老区的内存清理。
若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常:
java. lang.OutOfMemoryError:Java heap space
总结:
- 针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to。
- 关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。
对象分配流程图(含特殊情况):
VisualVM状态图分析:
常用的调优工具:
- JDK命令行
- Eclipse : Memory Analyzer Tool
- Jconsole
- VisualVM
- Jprofiler
- Java Flight Recorder
- GCViewer
- GC Easy
5、minor GC、Major GC、Full GC
1、GC的分类
- JVM在进行GC时,并非每次都对上面三个内存(新生代、老年代、方法区)区域一起回收的,大部分时候回收的都是指新生代。
- 针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)
- 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC / Young GC):只是新生代(Eden/S0、S1)的垃圾收集
- 老年代收集(MajorGC/Old GC):只是老年代的垃圾收集。
- 目前,只有CMS GC会有单独收集老年代的行为。
- 注意,很多时候Major GC会和Fu1l GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
- 目前,只有G1 GC会有这种行为
- 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。
- 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
2、最简单的分代式GC策略的触发条件
年轻代GC(Minor GC)触发机制:
- 当年轻代空间不足时, 就会触发Minor GC, 这里的年轻代满指的是Eden代满,Survivor满不会引发GC。( 每次Minor GC会清理年轻代的内存。)
- 因为Java对象大多都具备朝生夕灭的特性,所以MinorGC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
- Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
老年代GC (Major GC/Full GC)触发机制:
- 指发生在老年代的GC,对象从老年代消失时,我们说“Major GC”或“Full GC”发生了。
- 出现了Major GC,经常会伴随至少一次的Minor GC (但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。
- 也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC
- MajorGC的速度一般会比MinorGC慢10倍以上,STW的时间更长。
- 如果Major GC后,内存还不足,就报OOM了。
- Major GC的速度一般会比Minor GC慢10倍以上。
Full GC触发机制:(后面细讲)
- 触发Full GC执行的情况有如下五种:
- 调用System.gc()时,系统建议执行Full GC,但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区、survivor space0 (From Space)区向survivor space1 (To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
- 说明: full gc是开发或调优中尽量要避免的。这样暂时时间会短一些。
- 触发Full GC执行的情况有如下五种:
6、堆空间分代思想
为什么需要把java堆分代?不分代就不能正常工作了吗?
经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。
- 新生代:有Eden、两块大小相同的Survivor (又称为from/to, s0/s1)构成,to总为空。
- 老年代:存放新生代中经历多次GC仍然存活的对象。
其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。
而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方, 当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
7、内存分配策略(或对象提升(Promotion)规则)
如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对 象年龄设为1。对象在Survivor区中每熬过一次MinorGC ,年龄就增加1 岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、 每个GC都有所不同)时,就会被晋升到老年代中。
对象晋升老年代的年龄阈值,可以通过选项-XX: MaxTenuringThreshold
来设置。
针对不同年龄段的对象分配原则如下所示:
- 优先分配到Eden
- 大对象直接分配到老年代
- 尽量避免程序中出现过多的大对象(特别是朝生夕死的大对象),防止过多的STW
- 长期存活(15岁)的对象分配到老年代
- 动态对象年龄判断
- 如果Survivor 区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
- 空间分配担保
-XX: HandlePromotionFailure
8、为对象分配内存:TLAB
1、为什么有TLAB ( Thread Local Allocation Buffer ) ?
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
2、什么是TLAB?
- 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
- 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
- 据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。
3、TLAB相关说明
- 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
- 在程序中,开发人员可以通过选项“
-XX:UseTLAB
”设置是否开启TLAB空间。默认为开启。 - 默认情况下,TLAB空间的内存非常小,**仅占有整个Eden空间的1%**,当然我们可以通过选项“
-XX:TLABWasteTargetPercent
”设置TLAB空间所占用Eden空间的百分比大小。 - 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
9、小结堆空间的常用的JVM参数设置
官网说明:
- -XX: +PrintFlagsInitial:查看所有的参数的默认初始值
- -XX: +PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
- -Xms:初始堆空间内存 (默认为物理内存的1/64)
- -Xmx:最大堆空间内存(默认为物理内存的1/4)
- -Xmn:设置新生代的大小。(初始值及最大值)
- -XX:NewRatio: 配置新生代与老年代在堆结构的占比
- -XX: SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
- -XX: MaxTenuringThreshold:设置新生代垃圾的最大年龄
- -XX: +PrintGCDetails:输出详细的GC处理日志
- 打印gc简要信息:
- -XX: +PrintGC
- -verbose: gc
- XX: HandlePromotionFailure:是否设置空间分配担保
在发生MinorGC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
- 如果大于,则此次Minor GC是安全的
- 如果小于,则虚拟机会查看-XX: HandlePromotionFailure设置值是否允许担保失败。
- 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
- 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
- 如果小于,则改为进行一次Full GC。
- 如果HandlePromotionFailure=false,则改为进行一次Full GC。
- 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
在JDK6 Update24(JDK7)之后,HandlePromotionFailure参数不会再影响到虛拟机的空间分配担保策略,观察0penJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK6 Update24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC, 否则将进行Full GC。
10、堆是分配对象的唯一选择吗?
1、堆是分配对象的唯一选择吗?
在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:
随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis) 后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
此外,前面提到的基于openJDK深度定制的TaoBaoVM,其中创新的GCIH (GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。
2、逃逸分析概述
1、什么是逃逸分析?
如何将堆上的对象分配到栈,需要使用逃逸分析手段。
这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
通过逃逸分析,Java HotSpot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
1
2
3
4
5public void my_method() {
V v = new V();
//use v
v = null;
}没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。
当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
1
2
3
4
5
6public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer() ;
sb.append(s1);
sb.append (s2);
return sb;
}发生了逃逸的对象StringBuffer,作为返回值被返回回去了,在方法外可以被调用
改进代码,让StringBuffer sb逃不出去:(转换为String类型)
1
2
3
4
5
6public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1) ;
sb.append(s2) ;
return sb.toString() ;
}
2、逃逸分析的几种情况:
如何快速的判断是否发生了逃逸分析,大家就看**new的对象实体
是否有可能在方法外被调用**。
1 | /** |
3、逃逸分析相关的参数设置:
- 在JDK 6u23(JDK7)版本之后,HotSpot中默认就已经开启了逃逸分析。
- 如果使用的是较早的版本,开发人员则可以通过:
- 选项“
-XX: +DoEscapeAnalysis
“显式开启逃逸分析 - 通过选项“
-XX: +PrintEscapeAnalysis
“查看逃逸分析的筛选结果。
- 选项“
4、结论:
开发中能使用局部变量的,就不要使用在方法外定义。
5、逃逸分析的代码优化
使用逃逸分析,编译器可以对代码做如下优化:
- 栈上分配。将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
- 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
6、代码优化之栈上分配
- JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
- 常见的栈上分配的场景
- 在逃逸分析中,已经说明了。分别是给成员变量赋值、方法返回值、实例引用传递。
代码:
1 | /** |
结果分析:
在关闭逃逸分析的时候:代码执行时间:55ms,发生了GC
在开启逃逸分析的时候:代码执行时间:4ms,并没有发生GC
结论:
逃逸分析优化了对非逃逸对象的内存分配,实现了栈上分配。加快了程序的执行效率,并消除了GC,没有了STW,用户线程不会被阻碍。
7、代码优化之同步省略(锁消除)
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
- 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的
同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
代码:
1 | public class SynchronizedTest { |
代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。(在字节码文件下依旧存在synchronized的身影,即:字节码当中的monitorenter与monitorexit中间包裹的部分,只是在运行阶段进行了代码优化)
优化成:
1 | public void f() { |
8、代码优化之标量替换
**标量(Scalar)**是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做**聚合量(Aggregate)**,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
代码:
1 | /** |
结果分析:
在关闭标量替换的时候:代码执行时间:57ms,发生了GC
在开启标量替换的时候:代码执行时间:4ms,并没有发生GC
结论:
代码优化:(在alloc()方法中)
1
2
3
4public static void alloc() {
public int id = 5;
public String name = "www.atguigu.com";
}可以看到,Uesr这个聚合量经过逃逸分析后,发现它并没有逃逸,就被替换成两个标量了。
那么标量替换有什么好处呢?
就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。
标量替换为栈上分配提供了很好的基础。
另外,在开启
-XX:+DoEscapeAnalysis
即逃逸分析,同时关闭标量替换的时候为什么还会存在GC?可以理解为(弹幕,不一定对):
- 栈上分配是要基于标量替换,即使开启了逃逸分析但是没有开启标量替换,他还是会在堆上分配。
- 对象未发生逃逸 + 开启标量替换 = 栈上分配
- 可以将“栈上分配”理解为1个概念,具体要通过逃逸分析和标量替换两个参数决定
标量替换参数设置:
参数
-XX: +EliminateAllocations
:开启了标量替换(默认打开),允许将对象打散分配在栈上。上述代码在主函数中进行了1亿次alloc。调用进行对象创建,由于User对象实例需要占据约16字节的空间,因此累计分配空间达到将近1.5GB。如果堆空间小于这个值,就必然会发生GC。 使用如下参数运行上述代码:(逃逸分析在JDK7之后已经自动添加,这里针对的是服务器端)
- -server
- -Xmx100m
- -Xms100m
- -XX: +DoEscapeAnalysis
- -XX: +PrintGC
- -XX: +EliminateAllGcations
这里使用参数如下:
- 参数-server:启动Server模式,因为在Server模式下,才可以启用逃逸分析。(java的JVM默认就是一个Server模式,不用我们手动开启)
- 参数-XX:+DoEscapeAnalysis:启用逃逸分析
- 参数-Xmx10m:指定了堆空间最大为10MB
- 参数-xx: +PrintGC:将打印GC日志。
- 参数-XX:+E1 iminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上,比如对象拥有id和name两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配。
9、逃逸分析总结
- 关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。
- 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
- 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
- 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
- 注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。据我所知,Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
- 目前很多书籍还是基于JDK 7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。
11、堆总结
- 年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
- 老年代放置长生命周期的对象,通常都是从Survivor区域筛选拷贝过来的Java对象。当然,也有特殊情况,我们知道普通的对象会被分配在TLAB上;如果对象较大,JVM会试图直接分配在Eden其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代。
- 当GC只发生在年轻代中,回收年轻代对象的行为被称为MinorGC。当GC发生在老年代时则被称为MajorGC或者Ful1GC。一般的,MinorGC 的发生频率要比MajorGC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。
9、方法区(Method Area)
1、栈、堆、方法区的交互关系
从线程是否共享的角度来看
从代码看出栈、堆、方法区的交互关系:
1 | Person person = new Person(); |
2、方法区的理解
1、官方文档:
2、方法区的位置
《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotSpot JVM而言,方法区还有一个别名叫做**Non-Heap (非堆)**,目的就是要和堆分开。
所以,方法区看作是一块独立于Java堆的内存空间。
3、方法区和基本理解
- 方法区(Method Area) 与Java堆一样,是各个线程共享的内存区域。
- 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
- 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虛拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space (JDK7)或者 java.lang.OutOfMemoryError: Metaspace(JDK8)
- 加载大量的第三方的jar包;Tomcat 部署的工程过多(30-50个),大量动态的生成反射类
- 关闭JVM就会释放这个区域的内存。
4、Hotspot中方法区的演进
在JDK7及以前,习惯上把方法区,称为永久代。JDK8开始,使用元空间取代了永久代。
In JDK8,classes metadata is now stored in the native heap and this space is called Metaspace.
本质上,方法区和永久代并不等价。仅是对hotspot而言的。《Java虚拟机规范》对如何实现方法区,不做统一要求。 例如: BEA JRockit/ IBM J9中不存在永久代的概念。
- 现在来看,当年使用永久代,不是好的idea。导致Java程序更容易OOM (超过-XX : MaxPermSize上限)
而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间( Metaspace)来代替。
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虛拟机设置的内存中,而是使用本地内存。
永久代、元空间二者并不只是名字变了,内部结构也调整了。
- 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常。
3、设置方法区大小与OOM
方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。
JDK7及以前:
通过
-XX:PermSize
来设置永久代初始分配空间。默认值是20.75M-XX:MaxPermSize
来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError : PermGen space
jdk8及以后:
元数据区大小可以使用参数
-XX :MetaspaceSize
和-XX :MaxMetaspaceSize
指定,替代上述原有的两个参数。默认值依赖于平台。windows下,-XX:MetaspaceSize是21M, -XX: MaxMetaspaceSize的值是-1, 即没有限制。
参数设置方法:
- jdk7及以前:-XX:PermSize=100m -XX:MaxPermSize=100m
- jdk8及以后:-XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m(这个一般不会改)
与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError: Metaspace
-XX : MetaspaceSize
:
设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX :MetaspaceSize值为21MB。这就是初始的高水位线,一 旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。
4、如何解决OOM
- 要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer) 对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(MemoryLeak)**还是内存溢出(Memory Overflow)。内存泄漏堆积会导致内存溢出,所以判断内存溢出第一步是查看内存是否泄漏**。
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots 引用链的信息,就可以比较准确地定位出泄漏代码的位置。
- 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms) ,与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
5、方法区的内部结构
1、方法区存储的内容
《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:
它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
2、方法区和内部结构
1、类型信息
对每个加载的类型( 类calss、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java. lang.object, 都没有父类)
- 这个类型的修饰符(public, abstract, final的某个子集)
- 这个类型直接接口的一个有序列表
2、域(Field)信息
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
域的相关信息包括:
- 域名称
- 域类型
- 域修饰符:public,private,protected,static,final,volatile,transient的某个子集
3、方法(Method)信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
- 方法名称
- 方法的返回类型(或void)
- 方法参数的数量和类型(按顺序)
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
- 方法的字节码 (bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
- 异常表(abstract和native方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
4、non-final的类变量
- 静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。
- 类变量被类的所有实例共享,即使没有类实例时你也可以访问它。
javac编译器自动搜集字节码中的类变量的赋值动作和静态代码块组成的语句
5、全局常量:static final
被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配。
3、运行时常量池 VS 常量池
方法区,内部包含了运行时常量池。
字节码文件,内部包含了常量池。
方法区的运行时常量池就是class字节码文件中的常量池经过类加载器进行加载之后存放进内存之后得到。
但由于方法区的运行时常量池是具备动态性,所以可能比字节码文件里的常量池要大。
要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。
要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table) ,**包括各种字面量和对类型、域和方法的
符号引用
**。
4、为什么需要常量池?
一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用
**。在动态链接的时候会用到运行时常量池**。
比如如下的代码:
1 | public class SimpleClass { |
Object obj = new Object();经过编译之后会生成如下字节码文件:
1 | 0: new #2 // Class java/ lang/ object |
虽然编译过后的class文件只有194字节,但是里面却使用了String、System、 PrintStream及object等结构。这里代码量其实已经很小了。如果代码多,引用到的结构会更多!这里就需要常量池了!
5、常量池中的内容
几种在常量池内存储的数据类型包括:
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
6、常量池小结
常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
7、运行时常量池
- 运行时常量池( Runtime Constant Pool) 是方法区的一部分。
- 常量池表( Constant Pool Table) 是Class文件的一部分,用于存放
编译期
生成的各种字面量与符号引用
**,这部分内容将在类加载后
存放到方法区的运行时常量池中**。 - 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
- JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过**
索引访问
**的。 - 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为
真实地址
。- 运行时常量池,相对于Class文件常量池的另一重要特征是:**
具备动态性
**。- String. intern( )
- 运行时常量池,相对于Class文件常量池的另一重要特征是:**
- 运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些。
- 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。
6、方法区使用举例
7、方法区的演进细节
1、方法区的演进
首先明确:只有HotSpot才有永久代。
BEA JRockit、 IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。
Hotspot中方法区的变化:
版本 | 描述 |
---|---|
jdk1.6及之前 | 有永久代(permanent generation),静态变量存放在永久代上 |
jdk1.7 | 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中 |
jdk1.8及之后 | 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆 |
2、元空间 VS 永久代
随着Java8 的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)。
由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。
这项改动是很有必要的,原因有:
为永久代设置空间大小是很难确定的。
在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。
“Exception in thread ‘dubbo client x.x connector’ java.lang OutOfMemoryError: PermGen space
而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
对永久代进行调优是很困难的。
3、StringTable的调整
StringTable为什么要调整?
jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。这就导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
4、静态变量的位置
结论:静态引用对应的对象实体始终都存在堆空间
代码:
1 | /** |
staticObj(静态变量)随着Test的类型信息存放在方法区,instanceObj(实例变量)随着Test的对象实例存放在Java堆,localObject(局部变量)则是存放在foo( )方法栈帧的局部变量表中。
三个对象的数据在内存中的地址都落在Eden区范围内。
所以结论:只要是对象实例必然会在Java堆中分配。
接着,找到了一个引用该staticObj对象的地方,是在一个java.lang.Class的实例里,并且给出了这个实例的地址,通过Inspector查看该对象实例,可以清楚看到这确实是一个java.lang.Class类型的对象实例,里面有一个名为staticObj的实例字段:
从《Java虚拟机规范》所定义的概念模型来看,所有Class相关的信息都应该存放在方法区之中,但方法区该如何实现,《Java虚拟机规范》 并未做出规定,这就成了一件允许不同虚拟机自己灵活把握的事情。
JDK 7及其以后版本的HotSpot虚拟机选择把静态变量与类型在Java语言一端的映射Class对象存放在一起,存储于Java堆之中,从我们的实验中也明确验证了这一点。
8、方法区的垃圾回收
有些人认为方法区如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》 对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。
一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虛拟机对此区域未完全回收而导致内存泄漏。
**方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量
和不再使用的类型
**。
- 先来说说方法区内**常量池之中主要存放的两大类常量:
字面量
和符号引用
**。
字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。 - 而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
- HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
- 回收废弃常量与回收Java堆中的对象非常类似。
- 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
- Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了
-Xnoclassgc
参数进行控制,还可以使用-verbose:class
以及-XX: +TraceClass-Loading
、-XX: +TraceClassUnLoading
查看类加载和卸载信息 - 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
9、总结
10、常见面试题
- 百度:
- 三面:说一下JVM内存模型吧,有哪些区?分别干什么的?
- 蚂蚁金服:
- Java8的内存分代改进
- JVM内存分哪几个区,每个区的作用是什么?
- 一面: JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区?
- 二面: Eden和Survior的比例分配
- 小米:
- jvm内存分区,为什么要有新生代和老年代
- 字节跳动:
- 二面: Java的内存分区
- 二面:讲讲jvm运行时数据库区
- 什么时候对象会进入老年代?
- 京东:
- JVM的内存结构,Eden和Survivor比例。
- JVM内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为Eden和Survivor。
- 天猫:
- 一面: Jvm内存模型以及分区,需要详细到每个区放什么。
- 一面: JVM的内存模型,Java8做了什么修改
- 拼多多:
- JVM 内存分哪几个区,每个区的作用是什么?
- 美团:
- java内存分配
- jvm的永久代中会发生垃圾回收吗?
- 一面: jvm内存分区,为什么要有新生代和老年代?
10、对象的实例化、内存布局与访问定位
1、对象的实例化
1、大厂面试题
- 美团:
- 对象在JVM中是怎么存储的?
- 对象头信息里面有哪些东西?
- 蚂蚁金服:
- 二面: java对象头里有什么
2、对象实例化
创建对象的字节码解析:
创建步骤:
判断对象对应的类是否加载、链接、初始化
虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。( 即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象。
为对象分配内存
首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。
如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。
如果内存规整,使用指针碰撞:
如果内存是规整的,那么虚拟机将采用的是**指针碰撞法(Bump The Pointer)**来为对象分配内存。意思是所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带有compact(整理)过程的收集器时,使用指针碰撞。
如果内存不规整,虚拟机需要维护-一个列表,使用空闲列表分配:
如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为“空闲列表(Free List)“。
说明:选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
处理并发安全问题
在分配内存空间时,另外一个问题是及时保证new对象时候的线程安全性:创建对象是非常频繁的操作,虚拟机需要解决并发问题。虚拟机采用了两种方式解决并发问题:
- CAS ( Compare And Swap )失败重试、区域加锁:保证指针更新操作的原子性;
- TLAB把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区,(TLAB ,Thread Local Allocation Buffer) 虚拟机是否使用TLAB,可以通过
-XX: +/-UseTLAB
参数来设定(’+’:打开 ‘-‘:关闭)。
初始化分配到的空间
内存分配结束,虚拟机将分配到的内存空间都**初始化为零值(不包括对象头)**。这一步保证了对象的实例字段在Java代码中可以不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。
设置对象的对象头
将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。
执行init方法进行初始化
在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
因此一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。
2、对象的内存布局
小结:图示
3、对象的访存定位
图示:JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?
1、句柄访问
图示:
好处:reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改。
2、直接指针(HotSpot采用)
图示:
11、直接内存(Direct Memory)
1、直接内存概述
- 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
- 直接内存是在Java堆外的、直接向系统申请的内存区间。
- 来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
- 通常,访问直接内存的速度会优于Java堆。即读写性能高。
- 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
- Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
2、IO VS NIO
IO | NIO (New IO / Non-Blocking IO:非阻塞IO) | |
---|---|---|
实现 | byte[] / char[] | Buffer(缓存区) |
形式 | Stream(流) | Channel(管道) |
3、直接缓存区(IO) VS 非直接缓存区(NIO)
读写文件,需要与磁盘交互,需要由用户态切换到内核态。在内核态时,需要内存如下图的操作。
使用IO,见下图。这里需要两份内存存储重复数据,效率低。
使用NIO时,如下图。
操作系统划出的直接缓存区可以被java代码直接访问,只有一份。 NIO适合对大文件的读写操作。
4、直接内存的OOM异常
直接内存也可能导致OutOfMemoryError异常
由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。
缺点
- 分配回收成本较高
- 不受JVM内存回收管理
直接内存大小可以通过
MaxDirectMemorySize
设置如果不指定,默认与堆的最大值-Xmx参数值一致
简单理解:
java process memory = java heap + native memory
12、执行引擎
1、执行引擎概述
1、执行引擎的作用
- 执行引擎是Java虛拟机核心的组成部分之一。
- “虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
- JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。
- 那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。
2、执行引擎的工作过程
- 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器。
- 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。
- 当前方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。
从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
2、Java代码编译和执行过程
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤。
Java代码编译是由Java源码编译器来完成,流程图如下所示:
Java字节码的执行是由JVM执行引擎来完成,流程图如下所示:
问题1:什么是解释器( Interpreter),什么是JIT编译器?
- 解释器:当Java虛拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
- JIT (Just In Time Compiler) 编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。
- 解释器:边逐行翻译边运行
- 编译器:一起编译好再执行
问题2:为什么说Java是半编译半解释型语言?
JDK1.0时代,将Java语言定位为“解释执行”还是比较准确的。再后来,Java也发展出可以直接生成本地代码的编译器。
现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。
经过编译器编译之后可以在方法区中进行缓存(热点代码)
3、机器码、指令、汇编语言
1、理解执行引擎
2、机器码
- 各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。
- 机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。
- 用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快。
- 机器指令与CPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同。
3、指令与指令集
- 指令
- 由于机器码是有0和1组成的二进制序列,可读性实在太差,于是人们发明了指令。指令就是把机器码中特定的0和1序列,简化成对应的指令(一般为英文简写,如mov,inc等),可读性稍好
- 由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。
- 指令集
- 不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。
- 如常见的
- x86指令集,对应的是x86架构的平台
- ARM指令集,对应的是ARM架构的平台
4、汇编语言
- 由于指令的可读性还是太差,于是人们又发明了汇编语言。
- 在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol) 或标号(Label)代替指令或操作数的地址。
- 在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。
- 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。
5、高级语言
- 为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言。
- 当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。
6、字节码
- 字节码是一种**中间状态(中间码)的二进制代码(文件)**,它比机器码更抽象,需要直译器转译后才能成为机器码
- 字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。(实现跨平台)
- 字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。
- 字节码的典型应用为Java bytecode。
7、C/C++源程序执行过程
编译过程又可以分成两个阶段:编译和汇编。
- 编译过程:是读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码
- 汇编过程:实际上指把汇编语言代码翻译成目标机器指令的过程。
4、解释器
1、解释器概述
JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。
为什么需要字节码文件作为中间过渡,而不是采用将java源文件直接编译成对应的不同操作系统的机器指令的方式(此方式也可以实现跨平台)?
字节码文件是为了提高编译器的效率,同时也是Java虚拟机被称为跨语言的平台的基础。
2、解释器的工作机制(或工作任务)
- 解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
- 当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
3、解释器分类
在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。
- 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
- 模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
- 在HotSpot VM中,解释器主要由Interpreter模块和Code模块构成。
- Interpreter模块:实现了解释器的核心功能
- Code模块:用于管理HotSpot VM在运行时生成的本地机器指令
- 在HotSpot VM中,解释器主要由Interpreter模块和Code模块构成。
4、解释器现状
- 由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python、Perl、Ruby等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些
C/C++程序员所调侃。 - 为了解决这个问题,JVM平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
- 不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。
5、JIT编译器
1、Java代码的执行分类
第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行
第二种是编译执行(直接编译成机器码)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT, Just In Time)将方法编译成机器码后再执行
- HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代
码的时间和直接解释执行代码的时间。 - 在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C++程序一 较高下的地步。
2、为什么需要解释器?
有些开发人员会感觉到诧异,既然HotSpot VM中 已经内置JIT编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?比如JRockit VM内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。
首先明确:
当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。
总结成一句话:解释器的响应速度快,执行速度慢;而编译器的响应速度慢,执行速度快。
所以:
尽管JRockit VM中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当Java 虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。
同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”。
3、HotSpot JVM 的执行方式
当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。
相关案例:
注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。
热机状态:已经启动很长时间;冷机状态:刚刚启动
在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部宕机,此故障说明了JIT的存在。——阿里团队
4、JIT即时编译器
1、概念解释
- Java语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“ 编译器的前端”更准确一些)把. java文件转变成.class文件的过程;
- 也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程。
- 还可能是指使用静态提前编译器 (AOT编译器,Ahead Of Time Compiler) 直接把.java文件编译成本地机器代码的过程。
相关的编译器:
- 前端编译器:Sun的Javac、Eclipse JDT中的增量式编译器( ECJ)
- JIT编译器:HotSpot VM的C1、C2编译器
- AOT编译器:GNU Compiler for the Java (GCJ)、Excelsior JET。
2、是否选择使用编译器
热点代码及探测方式
当然是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能。
3、热点代码及探测方式
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR (On Stack Replacement)编译。
一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?
必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。
目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。
采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(BackEdge Counter) 。
- 方法调用计数器用于统计方法的调用次数
- 回边计数器则用于统计循环体执行的循环次数
4、方法调用计数器
这个计数器就用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次,在Server 模式下是10000 次。超过这个阈值,就会触发JIT编译。
这个阈值可以通过虛拟机参数
-XX:CompileThreshold
来人为设定。当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
方法调用计数器的工作流程图:
简化版本:
热度衰减
- 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay) ,而这段时间就称为此方法统计的半衰周期(Counter Half Life Time) 。
- 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数
-XX:UseCounterDecay
来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。 - 另外,可以使用
-XX:CounterHalfLifeTime
参数设置半衰周期的时间,单位是秒。
5、回边计数器
它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边” (Back Edge) 。显然,建立回边计数器统计的目的就是为了触发OSR编译。
回边计数器的执行流程图:
6、HotSpot VM可以设置程序执行方式
缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:
-Xint
:完全采用解释器模式执行程序;-Xcomp
: 完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。-Xmixed
:采用解释器+即时编译器的混合模式共同执行程序。(默认)
7、HotSpot VM中JIT分类
在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和Server Compiler,但大多数情况下我们简称为C1编译器和C2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器,如下所示:
-client
:指定Java虚拟机运行在Client模式下,并使用C1编译器;- C1编译器会对字节码进行简单和可靠的优化,耗时短。以达到更快的编译速度。
-server
:指定Java虚拟机运行在Server模式下,并使用C2编译器。(对于84位的操作系统默认就是Srver模式,不能修改)官方:
- C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。
分层编译(Tiered Compilation)策略:程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化。
不过在Java7版本之后,一旦开发人员在程序中显式指定命令“-server”时,默认将会开启分层编译策略,由C1编译器和C2编译器相互协作共同来执行编译任务。
C1和C2编译器不同的优化策略:
- 在不同的编译器上有不同的优化策略,C1骗译器上主要有方法内联,去虚拟化、冗余消除。
- 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
- 去虚拟化:对唯一的实现类进行内联
- 冗余消除:在运行期间把一些不会执行的代码折叠掉
- C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在C2上有如下几种优化:
- 标量替换:用标量值代替聚合对象的属性值
- 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
- 同步消除:清除同步操作,通常指synchronized
总结:
- 一般来讲,JIT编译出来的机器码性能比解释器高。
- C2编译器启动时长比C1编译器慢,系统稳定执行以后,C2编译器执行速度远远快于C1编译器。
8、最后补充
- 关于C1与C2:
- 自JDK10起,HotSpot又加入一个全新的即时编译器:Graal编译器。
- 编译效果短短几年时间就追评了C2编译器。未来可期。
- 目前,带着“实验状态”标签,需要使用开关参数去激活,才可以使用。
-XX: +UnlockExperimentalVMOptions
-XX: +UseJVMCICompiler
- 关于AOT编译器:(AOT VS JIT)
- jdk9引入了AOT编译器(静态提前编译器,Ahead Of Time Compiler)
- Java 9引入了实验性AOT编译工具jaotc。它借助了Graal编译器,将所输入的Java类文件转换为机器码,并存放至生成的动态共享库之中。
- 所谓AOT编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。
- .java -> .class -(jaotc)-> .so(机器指令)
- 最大好处:Java虛拟机加载已经预编译成二进制库,可以直接执行。不必等待即时编译器的预热,减少Java应用给人带来“第一次运行慢”的不良体验
- 缺点:
- 破坏了java“一次编译,到处运行”,必须为每个不同硬件、os编译对应的发行包。
- 降低了Java链接过程的动态性,加载的代码在编译期就必须全部已知。
- 还需要继续优化中,最初只支持Linux x64 java base
13、String Table
1、String的基本特性
String:字符串,使用一对
""
引起来表示。// 字面量的方式 String str = "Hello"; // new的方式 String string = new String("Hi");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
- String声明为final的,**不可被继承**
- String实现了Serializable接口:表示字符串是**支持序列化**的
- String实现了Comparable接口:表示String可以**比较大小**
- String在JDK8及以前内部定义了final char[] value用于存储字符串数据。**JDK9时改为byte[]**
- [官方](http://openjdk.java.net/jeps/254
):
![image-20210425194526787](JVM/image-20210425194526787.png)
![image-20210425201622470](JVM/image-20210425201622470.png)
- String:代表不可变的字符序列。简称:不可变性
- 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值
- 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
- 当调用String的replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
- 通过字面量的方式(区别与new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
- 字符串常量池中是不会存储相同内容的字符串的
- String的String Pool时一个固定大小的Hashtable,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长。而链表长了后直接会造成的影响就是当调用String.intern时性能会大幅下降。
- 使用`-XX: StringTableSize`可设置StringTable的长度
- 在JDK6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多的就会导致效率下降很快。StringTableSize设置没有要求
- 在JDK7中,StringTable的长度默认值是60013,StringTableSize设置没有要求
- 在JDK8开始,设置StringTable的长度的话,1009是可设置的最小值。
#### 2、String的内存分配
- 在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
- 常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,**String类型的常量池比较特殊。它的主要使用方法有两种**:
- 直接使用双引号`""`声明出来的String对象会直接存储在常量池中。
- ```java
String info = "Hello"如果不是用双引号
""
声明的String对象,可以使用String提供的intern()方法。
Java6及以前,字符串常量池存放在永久代。
Java7中Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内。
所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
字符串常量池概念原本使用比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java7中使用String.intern()。
Java8元空间,字符串常量池在堆
为什么StringTable要调整?
- permSize默认比较小
- 永久代垃圾回收的频率低
官网:
3、String的基本操作
Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例。
代码:
1 | class Memory { |
A string is created in line 7.it goes in the String Pool in the heap space and a reference is created in the foo() stack space for it.
4、字符串拼接操作
- 常量与插入的拼接结果在常量池,原理是编译期优化
- 常量池中不会存在相同内容的常量
- 只要其中有一个是变量,结果就在堆(非字符串常量池)中。变量拼接原理是StringBuilder(底层新建了一个StringBuilder对象进行字符串拼接)
- 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
相关案例:
总结:
如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果
intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。
如下的s1 + s2 的执行细节:(变量s是我临时定义的)
StringBuilder s = new StringBuilder();
s.append(“a”)
s.append(“b”)
s.toString() –> 约等于 new String(“ab”)
StringBuilder的toString()方法的new里面放的是char数组,不会在常量池创建对,而new String(“ab”)这里放的是字面量,会先在常量池创对象
补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer
字符串拼接操作不一定使用的是StringBuilder!
- 如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。
针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。
final修饰的变量一旦赋值后就不能再次赋值,所以可以做编译期优化,但是如果使用final String s = new String(“a”);则不会做编译期优化,必须运行时才能确定。
体会执行效率:通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!
详情:
- StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的对象
- 使用String的字符串拼接方式:创建过多个StringBuilder和String的对象
- 使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。
改进的空间:在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:
StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]
代码:
1 | public class StringTest5 { |
StringBuilder执行拼接操作:
- 好处:从始至终就创建了一个stringBuilder对象去执行append操作
- 改进空间:
- 可以使用StringBuilder的带参数的构造器,指定大小
- 如果调用默认构造器,初始容量16,进行大量存储操作时,会导致频繁扩容(数组大小是不可变的,所以得新建数组,然后进行数组间的copy,中间也会产生垃级对象,耗时耗力。
5、intern()的使用
1、Java.lang.String.intern()的相关解释
2、intern()的使用
如果不是用双引号声明的String对象,可以使用String提供的intern方法: intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
比如:
String myInfo = new String("I love atguigu").intern();
1
2
3
4
5
- 也就是说,如果在任意字符串上调用String.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true:
- ```java
("a" + "b" + "c").intern() == "abc"
通俗点讲,Interned String就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)。
- 如何保证变量s指向的是字符串常量池中的数据呢?
- 有两种方式:
- 方式一: String s = “shkstart”;//字面量定义的方式
- 方式二: 调用intern()
String s = new String("shkstart").intern();
String s = new StringBuilder("shkstart").toString().intern();
- 有两种方式:
3、面试题题目: new String(“ab”) 会创建几个对象? 拓展: new String(“a”) + new String (“b”)呢?
代码:
1 | public class StringNewTest { |
题目: new String(“ab”) 会创建几个对象?
- new String(“ab”)会创建几个对象?看字节码,就知道是两个。
- 一个对象是:new关键字在堆空间创建的
- 另一个对象是:字符串常量池中的对象”ab”。 字节码指令:ldc
拓展: new String(“a”) + new String (“b”)呢?
- 看字节码,知道是五个对象
- 对象1:new StringBuilder()
- 对象2: new String(“a”)
- 对象3: 常量池中的”a”
- 对象4: new String(“b”)
- 对象5: 常量池中的”b”
- 深入剖析: StringBuilder的toString():
- 对象6 :new String(“ab”)
- 强调一下,toString()的调用(底层用的是char[]数组创建的字符串),在字符串常量池中,没有生成”ab”
4、intern()的使用: jdk6 vs jdk7/8
代码:
1 | /** |
关于s3 == s4在JDK6 与 JDK7/8中答案不同的解析:
JDK6:在JDK6中,字符串常量池是放在永久代中
执行到String s3 = new String(“1”) + new String(“1”);的时候,程序在堆空间创建了一片空间用来存放字符串”11”,局部变量s3里存放着字符串”11”在堆空间当中的地址。
注意:此时的字符串常量池中并不存在字符串”11”(底层调用了StringBuilder的toString()方法,其中的new String使用char[]数组的方式创建了字符串”11”)
执行到s3.intern();的时候,intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。然而字符串常量池当中并不存在字符串”11”。所以,程序在字符串常量池中生成”11”对象。
执行到String s4 = “11”;的时候,由于在字符串常量池中存在了字符串”11”,所以局部变量s4里存放了字符串”11”在字符串常量池当中的引用(即地址)
由于变量s3的引用是堆当中字符串对象”11”的地址,而变量s4的引用是字符串常量池中字符串”11”的地址。两者并不相等。
JDK7/8:在JDK7/8中,字符串常量池放在堆中
- 执行String s3 = new String(“1”) + new String(“1”);与上面描述的一样
- 执行到s3.intern();的时候,由于字符串常量池是存放在堆空间当中,而字符串”11”对象也在堆空间,所以intern()方法在字符串常量池中创建字符串”11”的时候,直接将堆空间中的字符串”11”的地址存放进了字符串常量池的字符串”11”对象当中,即字符串常量池中存放的是堆空间中字符串”11”的引用。即变量s3的引用也指向了字符串常量池的字符串”11”
- 代码执行到String s4 = “11”;的时候,由于在字符串常量池中存在了字符串”11”,所以局部变量s4里存放了字符串”11”在字符串常量池当中的引用(即地址),然而字符串常量池中的字符串”11”存放的是堆空间的字符串”11”的地址。即:变量s4的引用也指向了堆空间在的字符串”11”的地址
- 这样一来堆空间与字符串常量池存放的字符串”11”为同一个对象。因此变量s3与变量s4指向的都是同一个地址,使用两者相等。
JDK6的执行图:
JDK7的执行图:
对上面案例在进行扩展:
代码:将 String s4 = “11”; 与 String s5 = s3.intern(); 执行顺序进行互换,并使用s5接收返回值
1 | public class StringIntern1 { |
5、总结String的intern()方法的使用
- jdk1. 6中,将这个字符串对象尝试放入串池。
- 如果字符串常量池中有,则并不会放入。返回已有的字符串常量池中的对象的地址
- 如果没有,会把此对象复制一份(新建对象),放入字符串常量池,并返回字符串常量池中的对象地址
- Jdk1.7起,将这个字符串对象尝试放入串池。
- 如果字符串常量池中有,则并不会放入。返回已有的字符串常量池中的对象的地址
- 如果没有,则会把对象的引用地址复制一份(没有新建对象),放入字符串常量池,并返回字符串常量池中的引用地址
6、关于intern()方法的两道练习
1、练习1:
代码:
1 | public class StringExer1 { |
图示:
放开 String x = “ab”; 注释之后的图示:
2、练习2
代码:
1 | public class StringExer2 { |
7、使用intern()测试执行效率:空间角度的使用上
两种方式创建字符串:
1 | arr[i] = new String(String.valueOf(data[i % data.length])); |
结论:对于程序中大量存在的字符串,尤其其中存在很多重复字符串时,使用intern()可以节省内存空间。
应用场景:
大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用intern()方法,就会明显降低内存的大小。
6、StringTable的垃圾回收
使用new String()的方式和使用new String().intern()的方式创建字符串都会在堆与字符串常量池创建字符串对象,但是为什么在存在大量重复的字符串的时候使用intern()会更节省内存空间呢? =》 答案:StringTable存在垃圾回收。
- 使用new String()的方式创建字符串不仅仅会在字符串常量池当中创建字符串对象(不重复),还会在堆空间当中创建大量的字符串对象(存在重复),这些堆空间的字符串对象都有一个变量的引用指向,GC不会进行垃圾回收。
- 使用new String().intern()的方式创建字符串虽然也会在堆空间和字符串常量池创建字符串对象,但是局部变量的指向的是字符串常量池的字符串对象,堆空间的字符串对象虽然也被创建了,但是没有变量的引用指向,会被GC回收。
7、G1的String去重操作
- 背景:对许多Java应用(有大的也有小的)做的测试得出以下结果:
- 堆存活数据集合里面String对象占了25%
- 堆存活数据集合里面重复的String对象有13.5%
- String对象的平均长度是45
- 许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合差不多25%是String对象。更进一步,这里面差不多一半string对象是重复的,重复的意思是说:string1.equals(string2)=true。堆上存在重复的String对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的String对象进行去重,这样就能避免浪费内存。
- 实现
- 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象。
- 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。
- 使用一个hashtable来记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。
- 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
- 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了。
- 命令行选项
- UseStringDeduplication(bool):开启String去重,默认是不开启的,需要手动开启。
- PrintStringDeduplicationStatistics(bool):打印详细的去重统计信息
- StringDeduplicationAgeThreshold(uintx):达到这个年龄的String对象被认为是去重的候选对象
14、垃圾回收概述
- 垃圾收集,不是Java语言的伴生产物。早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。
- 关于垃圾收集有三个经典问题:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
- 垃圾收集机制是Java的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。
1、什么是垃圾
- 什么是垃圾( Garbage) 呢
- 垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
- 外文: An object is considered garbage when it can no longer be reached from any pointer in the running program.
- 如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
- 内存溢出:存在引用指向不再被使用的对象,导致该对象无法被回收。比如匿名内部类存在指向外部类的引用等等。
2、为什么需要GC
- 对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。
- 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。
- 随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。
3、早期的垃圾回收
在早期的C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用new关键字进行内存申请,并使用delete关键字进行内存释放。比如以下代
码:1
2
3
4MibBridge *pBridge = new cmBaseGroupBridge();
//如果注册失败,使用Delete释放该对象所占内存区域
if (pBridge->Register(kDestroy) != NO_ERROR)
delete pBridge;这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回
收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃。在有了垃圾回收机制后,,上述代码块极有可能变成这样:
1
2MibBridge *pBridge = new cmBaseGroupBridge();
pBridge->Register(kDestroy);现在,除了Java以外,C#、Python、Ruby等语言都使用了自动垃圾回收的思想,也是未来发展趋势。可以说,这种自动化的内存分配和垃圾回收的方式己经成为现代开发语言必备的标准。
4、java垃圾回收机制
自动内存管理无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险
- 没有垃圾回收器,java也会和cpp一样,各种悬垂指针,野指针,泄漏问题让你头疼不已。
自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发
对于Java开发人员而言自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。
此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见OutOfMemoryError时,快速地根据错误异常日志定位问题和解决问题。
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术**实施必要的
监控
和调节
**。垃圾回收的区域:**
堆
(Heap)与方法区
(Method Area)**垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。
- 其中,**
Java堆
是垃圾收集器的工作重点**。
- 其中,**
从次数上讲:
- 频繁收集Young区
- 较少收集Old区
- 基本不动方法区(Perm区或元空间)
15、垃圾回收相关算法
- 判断对象存活
- 在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
- 那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
- 判断对象存活一般有两种方式:**
引用计数算法
和可达性分析算法
**。
- 回收垃圾
- 当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
- 目前在JVM中比较常见的三种垃圾收集算法是:**
标记一清除算法(Mark-Sweep)
、复制算法(Copying)
、标记-压缩算法(Mark-Compact)
**。
1、标记阶段:引用计数算法
1、引用计数算法概述
- 引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
- 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
- 优点:
- 实现简单,垃圾对象便于辨识;
- 判定效率高,回收没有延迟性。
- 缺点:
- 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
- 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
- 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。
2、循环引用
代码:
1 | /** |
图示:
3、小结
- 引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制。
- 具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
- Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
- Python如何解决循环引用?
- 手动解除:很好理解,就是在合适的时机,解除引用关系。
- 使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用。
2、标记阶段:可达性分析算法(或根搜索算法、追踪性垃圾收集)
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
相较于引用计数算法,这里的可达性分析就是Java、C#**选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集**(Tracing GarbageCollection)。
所谓”GC Roots”根集合就是一组必须活跃的引用。
基本思路:
- 可达性分析算法是以根对象集合(GC Roots) 为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
- 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
图示
在Java语言中,GC Roots包括以下几类元素:
- 虚拟机栈中引用的对象
- 比如:各个线程被调用的方法中使用到的参数、局部变量等。
- 本地方法栈内JNI (通常说的本地方法)引用的对象
- 方法区中类静态属性引用的对象
- 比如:Java类的引用类型静态变量
- 方法区中常量引用的对象
- 比如:字符串常量池(String Table)里的引用
- 所有被同步锁synchronized持有的对象
- Java虚拟机内部的引用。
- 基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError) ,系统类加载器。
- 反映java虛拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
- 虚拟机栈中引用的对象
图示:
除了这些固定的GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(Partial GC)
- 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。
小技巧:
- 由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。
注意:
- 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
- 这点也是导致GC进行时必须”
Stop The World
“的一个重要原因。- 即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
3、对象的finalization机制
- Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
- 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。
- finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
- 永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。理由包括下面三点:
- 在finalize() 时可能会导致对象复活。
- finalize() 方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
- 一个糟糕的finalize()会严重影响GC的性能。
- 从功能上来说,finalize()方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于C++中的析构函数。
- 由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。
- 如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件(finalize()方法)下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:
- 可触及的:从根节点开始,可以到达这个对象。
- 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
- 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。
- 以上3种状态中,是由于finalize()方法的存在进行的区分。只有在对象不可触及时才可以被回收。
- 判定一个对象objA是否可回收,至少要经历两次标记过程,具体过程:
- 如果对象objA到GC Roots没有引用链,则进行第一次标记。
- 进行筛选,判断此对象是否有必要执行finalize()方法
- 如果对象objA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。
- 如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、
低优先级
的Finalizer线程触发其finalize()方法执行。 - finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。
4、MAT与JProfiler的GC Roots溯源
1、MAT概述
MAT是Memory Analyzer的简称,它是一款功能强大的Java堆内存分析器。用于查找内存泄漏以及查看内存消耗情况。
MAT是基于Eclipse开发的,是一款免费的性能分析工具。
大家可以在官网下载并使用MAT。
2、获取dump文件
命令行使用jmap
使用JVisualVM导出
- 捕获的heap dump文件是一个临时文件,关闭JVisua1VM后自动删除,若要保留,需要将其另存为文件。
- 可通过以下方法捕获heap dump:
- 在左侧”Application”(应用程序)子窗口中右击相应的应用程序,选择Heap Dump(堆Dump)。
- 在Monitor (监视)子标签页中点击Heap Dump (堆Dump)按钮。
- 本地应用程序的Heap dumps作为应用程序标签页的一个子标签页打开。同时,heap dump 在左侧的Application (应用程序)栏中对应一个含有时间戳的节点。右击这个节点选择save as (另存为)即可将heap dump保存到本地。
3、使用MAT打开heap dump文件.hprof
File -> Open File -> 找到对应的.hprof文件导入
导入后图示:
在MAT中查看GC Roots的方法
GC Roots的相关展示:详情可查看官网
4、使用Profiler进行GC Roots溯源
Live memory -> All Objects -> View -> Mark Current Values (查看当前对象的个数)(光标变绿)
选择其中一个占内存较多的类 -> Show Selection In Heap Walker -> References(查看当前类的相关引用)
查看哪个对象在哪里被关联(用来解决内存泄漏问题:查看内存泄漏的相关对象在哪里被引用)
5、使用Profiler分析OOM
可以在代码中使用参数:
-XX: +HeapDumpOnOutOfMemoryError
,当程序出现OOM的时候在当前目录下自动生成Heap Dump文件Heap Walker -> Current Object Set -> Biggest Objects查看是否存在占用内存的超大对象
在Thread Dump处查看哪个线程的哪个位置出现了OOM
5、清除阶段:标记-清除算法
背景:
- 标记一清除算法( Mark-Sweep )是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并应用于Lisp语言。
执行过程:
当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world) ,然后进行两项工作,第一项则是标记,第二项则是清除。
- 标记:Collector从引用根节点开始遍历,标记
所有被引用的对象
**。一般是在对象的Header中记录为可达对象
**。 - 清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
- 标记:Collector从引用根节点开始遍历,标记
图示:
缺点:
- 效率不算高
- 在进行GC的时候,需要停止整个应用程序,导致用户体验差
- 这种方式清理出来的空闲内存是不连续的,产生内存碎片。
- 需要维护一个空闲列表
注意:何为清除(透明覆盖)
- 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。
- 下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。
6、清除阶段:复制算法
背景:
为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky于1963年发表了著名的论文,”使用双存储区的Lisp语言垃圾收集器CALISP Garbage Collector Algorithm Using Serial Secondary Storage)”M.L.Minsky在该论文中描述的算法被人们称为复制(Copying) 算法,它也被M.L.Minsky本人成功地引入到了Lisp语言的一个实现版本中。
核心思想:
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
图示:
优点:
- 没有标记和清除的过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现”碎片”问题
缺点:
- 需要两倍的内存空间
- 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
特别的:
- 如果系统中的垃圾对象很多,复制算法不会很理想。复制算法需要复制的存活对象数量要求不要太多,或者说非常少才行。
- 即特别适合垃圾对象很多,存活对象很少的场景;例如:Young区的Survivor0和Survivor1区
应用场景:
在新生代,对常规应用的垃圾回收,一次通常可以回收70% - 99%的内存空间。
回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
7、清除阶段:标记-压缩算法
背景:
- 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
- 标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。标记-压缩(Mark一Compact) 算法由此诞生。
- 1970年前后,G.L.Steele、C.J.Chene和D.s.Wise等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。
执行过程:
- 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
- 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
- 之后,清理边界外所有的空间。
图示:
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark- Sweep- Compact)算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
- 风险:所有引用到存活对象的引用都需要修改
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
指针碰撞:
- 如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump the Pointer) 。
优点:
- 消除了标记-清除算法当中内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。
- 消除了复制算法当中,内存减半的高额代价。
缺点:
- 从效率上来说,标记-整理算法要低于复制算法。
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
- 移动过程中,需要全程暂停用户应用程序。即: STW
8、小结
对比三种清除算法
Mark-Sweep | Mark-Compact | Copying | |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不会堆积碎片) | 通常需要活对象的两倍大小(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
再分配对象空间使用 | 空闲列表 | 指针碰撞 | 指针碰撞 |
效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。
而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。
9、分代收集算法
前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。
分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象, 由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
目前几乎所有的GC都是采用分代收集( Generational Collecting) 算法执行垃圾回收的。
在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。
- 年轻代(Young Gen)
- 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
- 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
- 老年代(Tenured Gen)
- 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
- 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
- Mark阶段的开销与存活对象的数量成正比。
- Sweep阶段的开销与所管理区域的大小成正比。
- Compact阶段的开销与存活对象的数量成正比。
以HotSpot中的CMS回收器为例,CMS是基于Mark- Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理。
分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。
10、增量收集算法、分区算法
上述现有的算法,在垃圾回收过程中,应用软件将处于一种Stop the World的状态。在Stop the World(SWT)状态下,应用程序所有的线程都会挂起,暂停切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集( Incremental Collecting) 算法的诞生。
基本思想:
- 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。(并发思想)
- 总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
优点:
- 使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。
缺点:
- 但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
分区算法:
一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。
为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间region。
每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。
图示:
注意,这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备。
16、垃圾回收相关概念
1、System.gc()的理解
- 在目录情况下,通过System.gc()或者runtime.getRuntime().gc()的调用,会显式触发Full GC,同时对老年代和新时代进行回收,尝试释放被丢弃对象占用的内存。
- 然而System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用。
- JVM实现者可以通过System.gc()调用来觉得JVM的GC行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()。
System.gc()提醒jvm的垃圾回收器执行gc,但是不确定是否马上执行gc,但是调用System.runFinalization()方法可以强制调用使用引用的对象的finalize()方法:
1 | public class SystemGCTest { |
System.gc()的相关案例:
问题:调用System.gc()无法保证对垃圾收集器的调用,为什么上述案例中,每次调用都会有垃圾回收信息输出?是进行了GC吗?
2、内存溢出(OOM)与内存泄漏(Memory Leak)
1、内存溢出(OOM)
内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。
由于GC一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现OOM的情况。
大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full GC操作,这时候会回收大量的内存供应用程序继续使用。
javadoc中对OutOfMemoryError的解释是:没有空闲内存,并且垃圾收集器也无法提供更多内存。
首先说没有空闲内存的情况:说明Java虛拟机的堆内存不够。原因有二:
Java虚拟机的堆内存设置不够。
比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数-Xms、-Xmx来调整。
代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
对于老版本的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。
2、内存泄漏(Memory Leak)
也称作”存储渗漏”。严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。
但实际情况很多时候一些不太好的实践 (或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做**宽泛意义上的”内存泄漏”**。
尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OutOfMemory异常,导致程序崩溃。
注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。
图示:
举例:
单例模式:
单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
一些提供close的资源未关闭导致内存泄漏:
数据库连接(dataSourse . getConnection()),网络连接(socket)和io连接必须手动close,否则是不能被回收的。
3、Stop The World
- Stop-The-World,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。
- 可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。
- 分析工作必须在一个能确保一致性的快照中进行
- 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
- 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
- 可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。
- 被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少STW的发生。
- STW事件和采用哪款GC无关,所有的GC都有STW这个事件。
- 哪怕是G1也不能完全避免Stop-the-world情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。
- STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。
- 开发中不要用System.gc();会导致Stop-the-world的发生。
4、垃圾回收的并行与并发
1、并发(Concurrent)
- 在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。
- 并发不是真正意义上的”同时进行”,只是CPU把一个时间段分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理的速度非常快,主要时间间隔处理得当,即可让用户感觉是多个应用程序在同时运行。
2、并行(Parallel)
- 当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel)。
- 其实决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以并行。
- 适合科学计算,后台处理等弱交互场景
3、并行 VS 并发
二者对比:
- 并发,指的是多个事情,在同一时间段内同时发生了。
- 并行,指的是多个事情,在同一时间点上同时发生了。
- 并发的多个任务之间是互相抢占资源的。
- 并行的多个任务之间是不互相抢占资源的。
- 只有在多CPU或者一个CPU多核的情况中,才会发生并行。
- 否则,看似同时发生的事情,其实都是并发执行的。
4、垃圾回收的并发与并行
并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:
并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
- 如并行的垃圾回收器:ParNew、 Parallel Scavenge、 Parallel Old;
串行(Serial)
- 相较于并行的概念,单线程执行。
- 如果内存不够,则程序暂停,启动JVM垃圾回收器进行垃圾回收。回收完,再启动程序的线程。
图示:
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。
- 用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;
- 如: CMS、G1
图示:
5、安全点与安全区域
1、安全点(Safepoint)
程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为”安全点(Safepoint)”
Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。
比如:选择些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。
如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?
抢先式中断:(目前没有虚拟机采用了)
首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
主动式中断:
设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。
2、安全区域
Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint 。但是,程序“不执行”的时候呢?
例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应JVM 的中断请求,”走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以把 Safe Region 看做是被扩展了的Safepoint。
实际执行时:
- 当线程运行到Safe Region的代码时,首先标识已经进入了Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程;
- 当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止;
6、在谈引用
我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。
[既偏门又非常高频的面试题]强引用、软引用、弱引用、虚引用有什么区别?
具体使用场景是什么?
在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(StrongReference)
、软引用(Soft Reference)
、弱引用(Weak Reference)
和虚引用(Phantom Reference)
4种,这4种引用强度依次逐渐减弱。(强软弱虚)
除强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。如下图,显示了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们。
Reference子类中只有终结器引用是包内可见的,其他3种引用类型均为public,可以在应用程序中直接使用
强引用(StrongReference)
**:最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。(不回收**)软引用(SoftReference)
**:在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。(内存不足即回收**)弱引用(WeakReference)
**:被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。(发现即回收**)虚引用(PhantomReference)
**:一个对象是否有虛引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。(对象回收的跟踪**)
7、再谈引用:强引用Strong Reference(不回收)
在Java程序中,最常见的引用类型是强引用(普通系统99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型。
当在Java语言中使用new操作符创建一个新的对象, 并将其赋值给一个变量的时候, 这个变量就成为指向该对象的一个强引用。
强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。
对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。
相对的,软引用、 弱引用和虚引用的对象是软可触及、弱可触及和虛可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成Java内存泄漏的主要原因之一。
强引用例子:
1 | StringBuffer str = new StringBuffer ("Hello"); |
局部变量str指向StringBuffer实例所在堆空间,通过str可以操作该实例,那么str就是StringBuffer实例的强引用
对应内存结构:
此时,如果再运行一个赋值语句:
1 | StringBuffer str1 = str; |
对应内存结构:
本例中的两个引用,都是强引用,强引用具备以下特点:
- 强引用可以直接访问目标对象。
- 强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出OOM异常,也不会回收强引用所指向对象。
- 强引用可能导致内存泄漏。
8、再谈引用:软引用Soft Reference(内存不足即回收)
软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。实例:Mybatis的一些内部类中就使用了软引用。
垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue) 。
类似弱引用,只不过Java虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。
在java doc中,软引用是这样描述的:
虚拟机在抛出 OutOfMemoryError 之前会保证所有的软引用对象已被清除。此外,没有任何约束保证软引用将在某个特定的时间点被清除,或者确定一组不同的软引用对象被清除的顺序。不过,虚拟机的具体实现会倾向于不清除最近创建或最近使用过的软引用。
软引用在我们的日常开发中使用的场景很多,比如商城中商品的信息。某个商品可能会被多人访问,此时我们可以把该商品的信息使用软引用保存。当系统内存足够时,可以实现高速查找,当系统内存不足又会被回收,避免OOM
的风险。
注意:
尽管软引用会在OOM之前被清理,但是,这并不表示Full GC会清理软引用对象。在经过Full GC后我们的软引用对象都放入了old区,由于Full GC的存在,程序大多数情况下并不会OOM。由于软引用对象占据了老年代的空间,Full GC将执行的更为频繁。所以还是建议使用弱引用。
当然,我们可以通过参数:-XX:SoftRefLRUPolicyMSPerMB=0
来设置当Full GC时回收软引用。其中参数值为Full GC保留的 SoftReference 数量,参数值越大,GC 后保留的软引用对象就越多。设置这个参数值为0时,Full GC就会回收我们的软引用对象了。
在JDK 1.2版之后提供了java.lang.ref.SoftReference类来实现软引用。
1 | Object obj = new Object(); //声明强引用 |
9、再谈引用:弱引用Weak Reference(发现即回收)
弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。
但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。
弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。
软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。
实例:
- 三级缓存(内存(弱引用) -> 本地 -> 网络)
ThreadLocal
的内部实现就是一个ThreadLocalMap
,该map
的Entry
的key
为ThreadLocal
本身,value
为我们向ThreadLocal
对象set
的值,其中的key
就是弱引用对象- 集合
WeakHashMap
,都是使用了弱引用实现的
在JDK 1.2版之后提供了java.lang.ref.WeakReference类来实现弱引用。
1 | Object obj = new Object(); //声明强引用 |
弱引用对象与软引用对象的最大不同就在于,当GC在进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC总是进行回收。弱引用对象更容易、更快被GC回收。
面试题:你开发中使用过WeakHashMap吗?
10、再谈引用:虚引用Phantom Reference(对象回收跟踪)
也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个。
一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。
它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法取得对象时,总是null。
为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。
虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。
由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。
在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
1 | Object obj = new Object(); |
对象回收跟踪的代码实例:
1 | import java.lang.ref.PhantomReference; |
11、再谈引用:终结器引用Final reference
- 它用以实现对象的finalize()方法,也可以称为终结器引用。
- 无需手动编码,其内部配合引用队列使用。
- 在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize()方法,第二次GC时才能回收被引用对象。
17、垃圾回收器
1、GC分类与性能指标
1、垃圾回收器概述
- 垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。
- 由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC版本。
- 从不同角度分析垃圾收集器,可以将GC分为不同的类型。
2、垃圾回收器分类
1、按线程数分,可以分为串行垃圾回收器和并行垃圾回收器
- 串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。
- 在诸如单CPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的
Client模式
下的JVM中 - 在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器。
- 在诸如单CPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的
- 和串行回收相反,并行收集可以运用多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-the-world”机制。
2、按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器
- 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
- 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。
3、按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器
- 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。
- 再分配对象空间使用:指针碰撞
- 非压缩式的垃圾回收器不进行这步操作。
- 再分配对象空间使用:空闲列表
4、按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器
3、评估GC的性能指标
- 吞吐量:运行用户代码的时间占总运行时间的比例
- (总运行时间:程序的运行时间十内存回收的时间)
- 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
- 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
- 收集频率:相对于应用程序的执行,收集操作发生的频率。
- 内存占用:Java堆区所占的内存大小。
- 快速:一个对象从诞生到被回收所经历的时间。
关于吞吐量、暂停时间与内存占用:
- 这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。
- 这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。
- 简单来说,主要抓住两点:
- 吞吐量
- 暂停时间
1、吞吐量(throughput)
吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间 / (运行用户代码时间+垃圾收集时间)
- 比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。
吞吐量优先,意味着在单位时间内,STW的时间最短:0.2 + 0.2 = 0.4
2、暂停时间(pause time)
“暂停时间”是指一个时间段内应用程序线程暂停,让GC线程执行的状态
- 例如,GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动的。
暂停时间优先,意味着尽可能让单次STW的时间最短: 0.1 + 0.1 + 0.1 + 0.1+0.1=0.5
3、吞吐量 VS 暂停时间
- 高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。
- 低暂停时间(低延迟)较好因为从最终用户的角度来看不管是GC还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。
- 不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。
- 因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收。
- 相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。
- 在设计(或使用) GC算法时,我们必须确定我们的目标:一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。
- 现在标准:在最大吞吐量优先的情况下,降低停顿时间。
2、不同的垃圾回收器概述
1、垃圾回收器发展史
有了虚拟机,就一定需要收集垃圾的机制,这就是Garbage Collection, 对应的产品我们称为Garbage Collector(GC)。
- 1999年随JDK1.3.1一起来的是串行方式的Serial GC,它是第一款GC。ParNew垃圾收集器是Serial收集器的多线程版本
- 2002年2月26日,Parallel GC和Concurrent Mark Sweep GC跟随JDK1.4.2一起发布
- Parallel GC在JDK6之后成为HotSpot默认GC。
- 2012年,在JDK1.7u4版本中,G1可用。
- 2017年,JDK9中G1变成默认的垃圾收集器,以替代CMS。
- 2018年3月,JDK10中G1垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。
- —–分水岭——
- 2018年9月,JDK11发布。引入Epsilon垃圾回收器,又被称为”No-Op (无操作) “回收器。同时引入ZGC:可伸缩的低延迟垃圾回收器(Experimental)。
- 2019年3月,JDK12发布。增强G1,自动返回未用堆内存给操作系统。同时,引入Shenandoah GC:低停顿时间的GC (Experimental)。
- 2019年9月,JDK13发布。增强ZGC,自动返回未用堆内存给操作系统。
- 2020年3月,JDK14发布。删除CMS垃圾回收器。扩展ZGC在macOS和Windows上的应用
2、七款经典的垃圾收集器
- 串行回收器: Serial、Serial Old
- 并行回收器: ParNew、Parallel Scavenge、Parallel Old
- 并发回收器: CMS、G1
七款经典收集器与垃圾分代之间的关系
- 新生代收集器: Serial、ParNew、Parallel Scavenge;
- 老年代收集器:Serial Old、Parallel Old、CMS;
- 整堆收集器:G1;
垃圾收集器的组合关系:
两个收集器间有连线,表明它们可以搭配使用:
Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
其中Serial Old作为CMS出现”Concurrent Mode Failure” 失败的后备预案。
(红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214) ,即:移除。
(绿色虚线)JDK 14中:弃用Parallel Scavenge和Serial Old GC组合(JEP366)
(青色虚线)JDK 14中:删除CMS垃圾回收器 (JEP 363)
为什么要有很多收集器,一个不够吗?
因为Java的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。
虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器。
如何查看默认的垃圾收集器:
XX:+PrintCommandLineFlags
:查看命令行相关参数(包含使用的垃圾收集器)- 使用命令行指令:
jinfo -flag 相关垃圾回收器参数进程ID
3、Serial回收器:串行回收
Serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1. 3之前回收新生代唯一的选择。
Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器。
Serial收集器采用复制算法、串行回收和**”Stop-the-World”机制**的方式执行内存回收。
除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。Serial Old收集器同样也采用了串行回收和”Stop the World”机制, 只不过内存回收算法使用的是标记-压缩算法。
- Serial Old是运行在Client模式下默认的老年代的垃圾回收器
- Serial Old在Server模式下主要有两个用途:
- 与新生代的Parallel Scavenge配合使用
- 作为老年代CMS收集器的后备垃圾收集方案
这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World) 。
优势:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
- 运行在Client模式下的虚拟机是个不错的选择。
在用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB),可以在较短时间内完成垃圾收集(几十ms至一百多ms) , 只要不频繁发生,使用串行回收器是可以接受的。
在HotSpot虚拟机中,使用
-XX: +UseSerialGC
参数可以指定年轻代和老年代都使用串行收集器。- 等价于新生代用Serial GC, 且老年代用Serial Old GC
总结:
- 这种垃圾收集器大家了解,现在已经不用串行的了。而且在限定单核cpu才可以用。现在都不是单核的了。
- 对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在Java web应用程序中是不会采用串行垃圾收集器的。
4、ParNew回收器:并行回收
如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本。
- Par是Parallel的缩写,New:只能处理的是新生代
ParNew收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、”Stop-the-World”机制。
ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器。
对于新生代,回收次数频繁,使用并行方式高效。
对于老年代,回收次数少,使用串行方式节省资源。 (CPU并行需要切换线程,串行可以省去切换线程的资源)
由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比Serial收集器更高效?
- ParNew
收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。 - 但是在单个CPU的环境下,ParNew收集器不比Serial收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地
做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
- ParNew
因为除Serial外,目前只有ParNew GC能与CMS收集器配合工作
在程序中,开发人员可以通过选项”
-XX: +UseParNewGC
“手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影
响老年代。-XX: ParallelGCThreads
限制线程数量,默认开启和CPU数据相同的线程数。(一般不超过CPU的数据)
5、Parallel回收器:吞吐量优先
HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样也采用了复制算法、并行回收和**”Stop the World”机制**。
那么Parallel收集器的出现是否多此一举?
- 和ParNew收集器不同,Parallel Scavenge收集器的目标则是达至一个可控制的吞吐量(Throughput)**,它也被称为吞吐量优先的垃圾收集器**。
- 自适应调节策略也是Parallel Scavenge 与ParNew一个重要区别。
高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
Parallel收集器在JDK1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来代替老年代的Serial Old收集器。
Parallel Old收集器采用了标记-压缩算法,但同样也是基于并行回收和**”Stop-the-World”机制**。
在程序吞吐量优先的应用场景中,Parallel收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。
在Java8中,默认是此垃圾收集器。
参数配置:
-XX: +UseParallelGC
:手动指定年轻代使用Parallel并行收集器执行内存回收任务。-XX: +UseParallelOldGC
:手动指定老年代都是使用并行回收收集器。- 分别适用于新生代和老年代。默认jdk8是开启的。
- 上面两个参数,默认开启一个,另一个也会被开启。 (互相激活)
-XX: parallelGCThreads
:设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。- 在默认情况下,当CPU数量小于8个, ParallelGCThreads的值等于CPU数量。
- 当CPU数量大于8个,ParallelGCThreads 的值等于3+[5 * CPU_Count] / 8]
-XX:MaxGCPaulseMillis
:设置垃圾收集器最大停顿时间(即STW的时间)。单位是毫秒。- 为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数。
- 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。
- 该参数使用需谨慎。
-XX: GCTimeRatio
:垃圾收集时间占总时间的比例(= 1 / (N + 1))。用于衡量吞吐量的大小。- 取值范围(0,100)。默认值99,也就是垃圾回收时间不超过1%。
- 与前一个-XX :MaxGCPauseMillis参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例。
-XX: +UseAdaptiveSizePolilcy
:设置Parallel Scavenge收集器具有自适应调节策略- 在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
- 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCT imeRatio)和停顿时间
(MaxGCPauseMills),让虚拟机自己完成调优工作。
6、CMS回收器:低延迟
在JDK 1.5时期,HotSpot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS (Concurrent -Mark -Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。
- 目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。
- CMS收集器就非常符合这类应用的需求。
CMS的垃圾收集算法采用标记-清除算法,并且也会**”Stop-the-world”**
不幸的是,CMS作为老年代的收集器,却无法与JDK 1.4.0中已经存在的新生代收集器Parallel Scavenge 配合工作,所以在JDK 1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。在G1出现之前,CMS使用还是非常广泛的。一直到今天,仍然有很多系统使用CMS GC。
CMS的工作原理:
CMS整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。
- 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GCRoots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
- 并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
- 重新标记(Remark) 阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
- 并发清除(Concurrent-Sweep) 阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
尽管CMS收集器采用的是并发回收(非独占式)**,但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制**暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“Stop-the-World”,只是尽可能地缩短暂停时间。
由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。
另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次”Concurrent Mode Failure“失败,这时虛拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
CMS收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer) 技术,而只能够选择空闲列表(Free List) 执行内存分配。
既然Mark Sweep会造成内存碎片,那么为什么不把算法换成Mark Compact呢?
- 答案其实很简答,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?
- 要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact更适合“Stop the World”这种场景下使用
CMS的优点:
- 并发收集
- 低延迟
CMS的弊端:
- 会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。
- CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
- CMS收集器无法处理浮动垃圾。可能出现”Concurrent Mode Failure“失败而导致另一次Full GC的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
CMS收集器可以设置的参数:
-XX: +UseConcMarkSweepGC
:手动指定使用CMS收集器执行内存回收任务。- 开启该参数后会自动将
-XX: +UseParNewGC
打开。 即:ParNew (Young区用) +CMS (Old区用) + Serial Old的组合。
- 开启该参数后会自动将
-XX:CMSlnitiatingOccupancyFraction
:设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。- JDK5及以前版本的默认值为
68
**,即当老年代的空间使用率达到68%时,会执行一次CMS回收。JDK6及 以上版本默认值为92%** - 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Full GC的执行次数。
- JDK5及以前版本的默认值为
-XX: +UseCMSCompactAtFullCollection
:用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。-XX: CMSFullGCsBeforeCompaction
:设置在执行多少次Full GC后对内存空间进行压缩整理。-XX:Parallel CMSThreads
:设置CMS的线程数量。- CMS默认启动的线程数是
(ParallelGCThreads+3) / 4
,ParallelGCThreads是年轻代并行收集器的线程数。当CPU资源比较紧张时,受到CMs收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。
小结:
- HotSpot有这么多的垃圾回收器,那么如果有人问,Serial GC、Parallel GC、Concurrent Mark Sweep GC这三个GC有什么不同呢?
- 请记住以下口令:
- 如果你想要最小化地使用内存和并行开销,请选Serial GC;
- 如果你想要最大化应用程序的吞吐量,请选Parallel GC;
- 如果你想要最小化GC的中断或停顿时间,请选CMS GC。
JDK后续版本中CMS的变化
JDK9新特性:CMS被标记为Deprecate了(JEP291)
- 如果对JDK9及以上版本的HotSpot虚拟机使用参数
-XX:+UseConcMarkSweepGC
来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃。
- 如果对JDK9及以上版本的HotSpot虚拟机使用参数
JDK14新特性:删除CMS垃圾回收器(JEP363)
移除了CMS垃圾收集器,如果在JDK14中使用
-XX: +UseConcMarkSweepGC
的话,JVM不会报错,只是给出一个warning信息,但是不会exit。JVM会自动回退以默认GC方式启动JVM1
OpenJDK 64-Bit Server VM warning: Ignoring option UseConcMarkSweepGC;support was removed in 14. 0 and the VM will continue execution using the default collector.
7、G1回收器:区域化分代式
1、两个问题
问题1:既然我们已经有了前面几个强大的GC,为什么还要发布Garbage First (G1)GC?
- 原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1 (Garbage-First) 垃圾回收器是在Java7 update 4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。
- 与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time) ,同时兼顾良好的吞吐量。
- 官方给G1设定的目标是
在延迟可控的情况下获得尽可能高的吞吐量
,所以才担当起”全功能收集器”的重任与期望。
问题2:为什么名字叫做Garbage First (G1) 呢?
- 因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region) (物理上不连续的)。使用不同的Region来表示Eden、 幸存者0区,幸存者1区,老年代等。
- G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
- 由于这种方式的侧重点在于回收垃圾最大量的区间(Region) ,所以我们给G1一个名字:垃圾优先(Garbage First) 。
2、G1概述
G1 (Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。
在JDK1.7版本正式启用,移除了Experimental的标识,是JDK 9以后的默认垃圾回收器,取代了CMS回收器以及Parallel + Parallel Old组合。被Oracle官方称为“全功能的垃圾收集器”。
与此同时,CMS已经在JDK 9中被标记为废弃(deprecated) 。在jdk8中还不是默认的垃圾回收器,需要使用-XX: +UseG1GC
来启用。
3、G1的特点(优势)与缺点
与其他GC收集器相比,G1使用了全新的分区算法,其特点有如下四点:
并行与并发
- 并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
- 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会出现在整个回收阶段发生完全阻塞应用程序的情况
分代收集
从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
将堆空间分为若千个区域(Region) ,这些区域中包含了逻辑上的年轻代和老年代。
和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代
空间整合
- CMS:“标记-清除”算法、内存碎片、若干次GC后进行一次碎片整理
- G1将内存划分为一个个的region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact )算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。
可预测的停顿时间模型(即:软实时soft real-time)
这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。(其中吞吐量 = M-N / M)
- 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
- G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集
时间,优先回收价值最大的Region。保证了G1 收集器在有限的时间内可以获取尽可能高的收集效率。 - 相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。
G1收集器的缺点:
- 相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint) 还是程序运行时的额外执行负载(Overload) 都要比CMS要高。
- 从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。
4、G1回收器的参数设置
-XX: +UseG1GC
**:手动指定使用G1收集器执行内存回收任务**。-XX: G1HeapRegionSize
**:设置每个Region的大小**。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/ 2000。-XX: MaxGCPauseMillis
**:设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms**-XX: ParallelGCThread
:设置STW时GC线程数的值。最多设置为8-XX: ConcGCThreads
:设置并发标记的线程数。将n设置为并行垃圾回收线数(ParallelGCThreads)的1/4左右。-XX: InitiatingHeapoccupancyPercent
:设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默值是45。堆空间已用占比达到45%,老年代才会并发标记
5、G1回收器的常见操作步骤
- G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:
- 第一步:开启G1垃圾收集器
- 第二步:设置堆的最大内存
- 第三步:设置最大的停顿时间
- G1中提供了三种垃圾回收模式:YoungGC、 Mixed GC和Full GC,在不同的条件下被触发。
6、G1回收器的适用场景
- 面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)
- 最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;
- 如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。
- 用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:
- 超过50%的Java堆被活动数据占用;
- 对象分配频率或年代提升频率变化很大;
- GC停顿时间过长(长于0.5至1秒)
- HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。
7、分区Region:化整为零
使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB
之间,且为2的N次幂,即1MB, 2MB, 4MB, 8MB, 16MB, 32MB。可以通过-XX:G1HeapRegionSize
设定。所有的Region大小相同,且在JVM生命周期内
不会被改变。
虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region (不需要连续)的集合。通过Region的动态分配方式实
现逻辑上的连续。
- 如果设置了Region数量,那么Region大小就不是固定的,但是大小肯定是2的幂次方,并且在1-32M之间;
- 如果设置了Region大小,那么Region数量就不是固定的,但是肯定是2048附近;
Region只能是Eden、Survivor、 Humongous中的一种,但是它的身份不是固定的,谁来占用那么这个Region就是谁的
- 一个region有可能属于Eden,Survivor 或者Old/Tenured 内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,s表示属于Survivor内存区域,O表示属于Old内存区域。图中空白的表示未使用的内存空间。
- G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过0.5个region,就放到H。region是可以连续分配的,小于0.5的就用两个连续的region分配
- object that is more than half a region size is considered a “Humongous object” :大于区域大小一半的对象都被视为“巨大对象”
设置Humongous的原因:
对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。
关于指针碰撞与空闲列表:
Bump:单个Region使用指针碰撞的方式来放数据上面allocated是已经使用的内存空间,top就是指针的位置,unallocate是没有使用的内存空间
- Bump-the-pointer,即:指针碰撞
TLAB:虽然存在分区Region,但是依然有线程独有的TLAB空间,这样可以保证多个线程对对象修改可以并行操作
- TLAB,即:空闲列表
8、G1回收器垃圾回收过程
G1 GC的垃圾回收过程主要包括如下三个环节:
- 年轻代GC (Young GC)
- 老年代并发标记过程(Concurrent Marking)
- 混合回收(Mixed GC)
- (如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。)
顺时针,Young GC -> Young GC +Concurrent Marking -> Mixed GC顺序,进行垃圾回收。
- 应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。
- 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。
- 标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。
- 举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45号%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。
G1回收器垃圾回收过程:Remembered Set
一个对象被不同区域引用的问题
一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?
在其他的分代收集器,也存在这样的问题(而G1更突出)
回收新生代也不得不同时扫描老年代?
- 这样的话会降低Minor GC的效率;
解决方法:
- 无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:
- 每个Region都有一个对应的Remembered Set;
- 每次Reference类型数据写操作时,都会产生一个Write Barrier 暂时中断操作;
- 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);
- 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;
- 卡表是记忆集的一种具体实现方式。 见《深入理解Java虚拟机》
- 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏;
- 无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:
上面提到的Remebered Set就是上述Reset,上面提到的Reference类型就是引用类型,其中Reset的作用是记录当前Region中哪些对象被外部引用指向,比如OId区中的对象会指向Eden区的对象,然后当我们要回收某个Region的时候,直接遍历遍历当前Region中的所有对象就可以了,然后针对性的去找到那些指向当前对象的其他对象,最终发现当前对象是否是根可达的,如果不是,那就应该被删除,其实之前的垃圾回收器都涉及到这个问题,当进行Minor GC的时候,通过GC Roots查找的时候还需要遍历Old区的对象,毕竟Old区对象也可能会指向Eden区对象,但是G1通过Rset避免了全堆的扫描,当引用类型数据写操作时,先暂时中断,然后判断当前引用类型数据是否被其他对象所指向,如果不被指向,那就直接放在Region中就可以了;如果被其他对象指向,那么还要判断这个对象是在当前要插入的Region中,还是在其他Region中;如果在其他Region中,那就需要使用CardTable把当前引用类型数据的指向信息放在Rset中,也就是形成上面的虚线连线,如果在当前Region中,那就不需要指向了,毕竟到时候我们会进行遍历查找根可达对象,那肯定会找到的,所以这种情况也是直接放在Region中就可以了。
9、G1回收过程一:年轻代GC
JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。
年轻代垃圾回收只会回收Eden区和Survivor区。
首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
然后开始如下回收过程:
第一阶段,扫描根。可以体现Rset作用:避免全堆扫描
- 根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
第二阶段,更新RSet。 作用:保证Rset中的数据准确性
处理dirty card queue中的card,更新RSet。此阶段完成后,RSet 可以准确的反映老年代对所在的内存分段中对象的引用。
对dirty card queue (脏卡表队列)的解释:
- 对于应用程序的引用赋值语句object.field=object,JVM会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。在年轻代回收的时候,G1会对Dirty Card Queue中所有的card进行处理,以更新RSet,保证RSet实时准确的反映引用关系。
- 其中object.field=object中的第一个object代表老年代中的对象,而第二个object代表Eden区中的对象
- 那为什么不在引用赋值语句处直接更新RSet呢?
- 这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多。
- 对于应用程序的引用赋值语句object.field=object,JVM会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。在年轻代回收的时候,G1会对Dirty Card Queue中所有的card进行处理,以更新RSet,保证RSet实时准确的反映引用关系。
脏卡表队列作用:
Reset更新需要线程同步,所以开销会很大,因此不能实时更新,因此我们需要把引用对象被其他对象引用的关系放在一个脏卡表队列中,当年轻代回收的时候会进行STW,所以我们也正好把脏卡表队列中的值更新到Rset中,这样不仅没有涉及到开销问题,还可以保证Rset中的数据是准确的。
第三阶段,处理RSet。作用:根可达性遍历的一部分
- 识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
第四阶段,复制对象。说明:新生代使用复制算法
- 此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
第五阶段,处理引用。空Eden:Eden变成空的,那它就变成了无主Region,因此会被记录到空链表中,等待下一次被分配
- 处理Soft,Weak,Phantom, Final, JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
- 以上回收的都是强引用对象,下面回收软引用对象 (不足回收)、弱引用对象(发现回收)、虚引用对象
10、G1回收过程二:并发标记过程
- 初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。
- 根区域扫描(Root Region Scanning)**:G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在Young GC之前完成。主要扫描哪些老年代对象是可达的**,毕竟我们进行Young GC的时候会移动Survivor区,移动之后就找不到哪些老年代对象是可达的了
- 并发标记(Concurrent Marking)**:在整个堆中进行并发标记(和应用程序并发执行),此过程可能被Young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收**(实时回收)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
- **再次标记(Remark)**:由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。
- 原因:并发标记不准确
- 独占清理(cleanup ,STW)**:计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。其实是一个统计计算过程,不会涉及垃圾清理**
- 这个阶段并不会实际上去做垃圾的收集
- 并发清理阶段:识别并清理完全空闲的区域。
- 并发清理阶段任务:如果发现区域对象中的所有对象都是垃圾,那么这个区域会被立即回收。
11、G1回收过程三:混合回收
当越来越多的对象晋升到老年代Old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。 这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。
- 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过
-XX: G1MixedGCCountTarget
设置)被回收。 - 混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。
- 由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,
-XX:G1MixedGCLiveThresholdPercent
,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。垃圾占比越多, 回收优先级越高;如果垃圾不足Region空间的65%,那么将不会进行回收。 - 混合回收并不一定要进行8次。有一个阈值
-XX:G1HeapWastePercent
,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。如果垃圾不足Region内存的10%,那么将不会对该老年代Region进行回收,综合上面的来看,只要垃圾占整个老年代Region的比例大于65%,才会对该Region进行回收
12、G1回收可选的过程四:Full GC
G1的初衷就是要避免Fu1l GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop- The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?
- 比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC,这种情况可以通过增大内存解决。
导致G1Full GC的原因可能有三个:
- Evacuation的时候没有足够的to-space来存放晋升的对象;
- 解决:加大堆空间
- 并发处理过程完成之前空间耗尽。
- 解决:调小触发并发GC周期的Java堆占用阈值(默认是45%, 在前面参数页有)
- 最大GC暂停时间太短,导致在规定的时间间隔内无法完成垃圾回收,也会导致Full GC
- 解决:加大最大GC停顿时间
13、G1回收过程:补充
从Oracle官方透露出来的信息可获知,回收阶段( Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region, 停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。
14、G1回收器优化建议
- 年轻代大小
- 避免使用
-Xmn
或-XX:NewRatio
等相关选项显式设置年轻代大小 - 固定年轻代的大小会覆盖暂停时间目标
- 原因:年轻代GC是并行独占式的,所以最好让垃圾回收器自己去调节
- 避免使用
- 暂停时间目标不要太过严苛
- G1 GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间
- 评估G1 GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。
- 说明:暂停时间和吞吐量是此消彼长的,所以不要把暂停时间设置的太严格,不然因为这个原因引起Full GC也不太好
8、垃圾回收总结
截止JDK 1.8,一共有7款不同的垃圾收集器。每一款不同的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。
垃圾收集器 | 分类 | 作用位置 | 使用算法 | 特点 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行运行 | 作用于新生代 | 复制算法 | 响应速度优先 | 适用于单CPU |
ParNew | 并行运行 | 作用于新生代 | 复制算法 | 响应速度优先 | 多CPU环境Server模式下与CMS配合使用 |
Parallel | 并行运行 | 作用于新生代 | 复制算法 | 吞吐量优先 | 适用于后台运算而不需要太多交互的场景 |
Serial Old | 串行运行 | 作用于老年代 | 标记-压缩算法 | 响应速度优先 | 适用于单CPU环境下的Client模式 |
Parallel Old | 并行运行 | 作用于老年代 | 标记-压缩算法 | 吞吐量优先 | 适用于后台运算而不需要太多交互的场景 |
CMS | 并发运行 | 作用于老年代 | 标记-清除算法 | 响应速度优先 | 适用于互联网或B/S业务 |
G1 | 并发、并行运行 | 作用于新生代、老年代 | 标记-压缩算法、复制算法 | 响应速度优先 | 面向服务端应用 |
GC发展阶段:Serial => Parallel (并行) => CMS (并发) => G1 => ZGC
怎么选择垃圾回收器?
- Java垃圾收集器的配置对于JVM优化来说是一个很重要的选择,选择合适的垃圾收集器可以让JVM的性能有一个很大的提升。
- 怎么选择垃圾收集器?
- 优先调整堆的大小让JVM自适应完成。
- 如果内存小于100M, 使用串行收集器
- 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
- 如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择.
- 如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器官方推荐G1,性能高。现在互联网的项目,基本都是使用G1。
- 最后需要明确一个观点:
- 没有最好的收集器,更没有万能的收集器;
- 调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器
- 面试:
- 对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。
这里较通用、基础性的部分如下:- 垃圾收集的算法有哪些?
- 如何判断一个对象是否可以回收?
- 垃圾收集器工作的基本流程。
- 另外,大家需要多关注垃圾回收器这一章的各种常用的参数。
- 对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。
9、GC日志分析
通过阅读GC日志,我们可以了解Java虚拟机内存分配与回收策略。
内存分配与垃圾回收的参数列表
-XX: +PrintGC
:输出Gc日志。类似: -verbose :gc-XX: +PrintGCDetails
:输出GC的详细日志-XX: +PrintGCTimeStamjps
:输出GC的时间戳(以基准时间的形式)-XX: +PrintGCDateStamps
:输出GC的时间戳(以日期的形式,如2013-05-04T21 :53:59.234+0800)-XX: + PrintHeapAtGC
:在进行GC的前后打印出堆的信息-Xloggc: ../logs/gc.log
:日志文件的输出路径
打开GC日志:
1
-verbose: gc
这个只会显示总的GC堆的变化, 如下:
1
2
3[GC (Allocation Failure) 80832K->19298K(227840K),0.0084018secs]
[GC (Metadata GC Threshold) 109499K->21465K (228352K),0.0184066 secs]
[Full GC (Metadata GC Threshold) 21465K->16716K(201728K) ,0.0619261secs ]参数解析:
- GC、Full GC:GC的类型,GC只在新生代上进行,Full GC包括永生代,新生代, 老年代。
- Allocation Failure: GC发生的原因。
- 80832K -> 19298K:堆在GC前的大小和GC后的大小。
- 228840k:现在的堆大小。
- 0.0084018 secs:GC持续的时间。
打开GC日志:
1
-verbose: gc -XX: +PrintGCDetails
输入信息如下:
1
2
3
4
5
6
7[GC (Allocation Failure)[PSYoungGen: 70640K->10116K(141312K)] 80541K->20017K (227328K),0.0172573secs]
[Times: user=0.03 sys=0.00, real=0.02 secs]
[GC (Metadata GC Threshold) [PSYoungGen: 98859K->8154K(142336K) ] 108760K->21261K (228352K),0.0151573 secs]
[Times: user=0.00 sys=0.01, real=0.02 secs]
[Full GC (Metadata GC Threshold) [PSYoungGen: 8154K->0K(142336K) ] [ParOldGen: 13107K->16809K(62464K)] 21261K -> 16809K (204800K),
[Metaspace: 20599K->20599K (1067008K)],0.0639732 secs]
[Times: user=0.14 sys=0.00, real=0.06 secs]参数解析:
- GC,Full FC:同样是GC的类型
- Allocation Failure:GC原因
- PSYoungGen:使用了Parallel Scavenge并 行垃圾收集器的新生代Gc前后大小的变化
- ParoldGen:使用了Parallel Old并行垃圾收集器的老年代GC前后大小的变化
- Metaspace:元数据区GC前后大小的变化,JDK1.8中引入了元数据区以替代永久代
- XXX secs:指GC花费的时间
- Times: user: 指的是垃圾收集器花费的所有CPU时间,sys: 花费在等待系统调用或系统事件的时间,real:GC从开始到结束的时间,包括其他进程占用时间片的实际时间。
打开GC日志:
1
-verbose:gc -XX: +PrintGCDetails -XX: +PrintGCTimeStamps -XX:+PrintGCDateStamps
输入信息如下:
1
2
3
4
52019-09-24T22:15:24.518+0800:3.287: [GC(Allocation Failure) [ PSYoungGen: 136162K->5113K (136192K) ] 141425K->17632K (222208K),0.0248249 secs] [Times: user=0. 05 sys=0.00,real=0.03 secs]
2019-09-24T22:15:25.559+0800:4.329: [GC (Metadata GC Threshold)[ PSYoungGen:97578K->10068K(274944K) ] 110096K->22658K (360960K),0.0094071 secs] [Times: user=0. 00 sys=0.00,real=0.01 secs]
2019-09-24T22:15:25.569+0800: 4.338: [Full GC (Metadata GC Threshold) [ PSYoungGen:10068K->0K(274944K) ] [ParOldGen: 12590K->13564K (56320K) ] 22658K->13564K (331264K) ,
[Metaspace: 20590K->20590K(1067008K)], 0.0494875 secs]
[Times: user=0.17 sys=0.02,real=0.05 secs]说明:带上了日期和时间
- 日志补充说明:
- “ [GC”和” [Full GC” 说明了这次垃圾收集的停顿类型,如果有”Full”则说明GC发生了”Stop The World”
- 使用Serial收集器在新生代的名字是Default New Generation,因此显示的是” [DefNew”
- 使用ParNew收集器在新生代的名字会变成” [ParNew”,意思是”Parallel New Generation”
- 使用Parallel Scavenge收集器在新生代的名字是” [PSYoungGen”
- 老年代的收集和新生代道理一样,名字也是收集器决定的
- 使用G1收集器的话,会显示为”garbage- first heap”
- Allocation Failure:表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了。
- [PSYoungGen: 5986K->696K(8704K) ] 5986K- > 704K (9216K)中
- 括号内:GC回收前年轻代大小,回收后大小,( 年轻代总大小)
- 括号外:GC回收前年轻代和老年代大小,回收后大小,( 年轻代和老年代总大小)
- user代表用户态回收耗时,sys 内核态回收耗时,rea实际耗时。由于多核的原因,时间总和可能会超过real时间
GC日志分析
Minor GC日志:
Full GC日志:
如果想把GC日志存到文件的话,是下面这个参数:
- Xloggc: ./path/to/gc. log
GC日志分析工具:
- 可以用一些工具去分析这些gc日志。
- 常用的日志分析工具有:GCViewer、GCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat等。
10、垃圾回收器的新发展
1、垃圾回收器的发展
GC仍然处于飞速发展之中,目前的默认选项G1 GC在不断的进行改进,很多我们原来认为的缺点,例如串行的Full GC、 Card Table扫描的低效等,都已经被大幅改进,例如,JDK 10以后,Full GC已经是并行运行,在很多场景下,其表现还略优于Parallel GC的并行Full GC实现。
即使是Serial GC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是GC相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在Serverless等新的应用场景 下,Serial GC找到了新的舞台。
比较不幸的是CMS GC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但在JDK9中已经被标记为废弃,并在JDK14版本中移除。
2、JDK11的新特性
3、Open JDK12的Shenandoah GC:低停顿时间的GC (实验性)
现在G1回收器已成为默认回收器好几年了。
我们还看到了引入了两个新的收集器:
- ZGC( JDK11出现)
- Shenandoah(Open JDK12)
- 主打特点:低停顿时间
Shenandoah,无疑是众多GC中最孤独的一个。是第一款不由Oracle公司团队领导开发的HotSpot垃圾收集器。不可避免的受到官方的排挤。比如号称OpenJDK和OracleJDK没有区别的Oracle公司仍拒绝在OracleJDK12中支持Shenandoah。
Shenandoah垃圾回收器最初由RedHat进行的一项垃圾收集器研究项目Pauseless GC的实现,旨在针对JVM上的内存回收实现低停顿的需求。在2014年贡献给OpenJDK。
Red Hat研发Shenandoah团队对外宣称,Shenandoah垃圾回收器的暂停时间与堆大小无关,这意味着无论将堆设置为200MB还是200GB,99.9%的目标都可以把垃圾收集的停顿时间限制在十毫秒以内。不过实际使用性能将取决于实际工作堆的大小和工作负载。
这是RedHat在2016年发表的论文数据,测试内容是使用Es对200GB的维基百科数据进行索引。从结果看:
- 停顿时间比其他几款收集器确实有了质的飞跃,但也未实现最大停顿时间控制在十毫秒以内的目标。
- 而吞吐量方面出现了明显的下降,总运行时间是所有测试收集器里最长的。
总结:
- Shenandoah GC的弱项:高运行负担下的吞吐量下降王
- Shenandoah GC的强项:低延迟时间。
- Shenandoah GC的工作过程大致分为九个阶段,这里就不再赘述。在之前Java12新特性视频里有过介绍。
[Java12新特性地址]
http://www.atguigu.com/download_detail.shtml?v=222
或
https://www.bilibili.com/video/BV1jJ411M7kQ?from=search&seid=12339069673726242866
4、令人震惊、革命性的ZGC(JDK14新特性)
ZGC与Shenandoah目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。
《深入理解Java虚拟机》一书中这样定义ZGC:ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了
读屏障
、染色指针
和内存多重映射
等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。ZGC的工作过程可以分为4个阶段:
- 并发标记
- 并发预备重分配
- 并发重分配
- 并发重映射等。
ZGC几乎在所有地方并发执行的,除了初始标记的是STW(10ms以内)的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。
测试数据:
低延迟:
在ZGC的强项停顿时间测试上,它毫不留情的将Parallel、G1拉开了两个数量级的差距。无论平均停顿、958停顿、99%停顿、99. 98停顿,还是最大停顿时间,ZGC 都能毫不费劲控制在10毫秒以内。
虽然ZGC还在试验状态,没有完成所有特性,但此时性能已经相当亮眼,用“令人震惊、革命性”来形容,不为过。
未来将在服务端、大内存、低延迟应用的首选垃圾收集器。
JEP 364: ZGC应用在macOS上
JEP 365: ZGC应用在Windows
- JDK14之前,ZGC仅Linux才支持。
- 尽管许多使用ZGC的用户都使用类Linux的环境,但在Windows和macOs.上,人们也需要ZGC进行开发部署和测试。许多桌面应用也可以从ZGC中受益。因此,ZGC特性被移植到了Windows和macOS上。
- 现在mac或windows.上也能使用zGc了,示例如下:
**-XX: +Unloc kExperimentalVMOptions -XX: +UseZGC**
其它垃圾回收器: AIiGC
AliGC是阿里巴巴JVM团队基于G1算法,面向大堆 (LargeHeap)应用场景。指定场景下的对比:
当然,其他厂商也提供了各种独具一格的GC实现, 例如比较有名的低延迟GC:Zing,有兴趣可以参考提供的链接。
18、垃圾回收的相关大厂面试题
- 蚂蚁金服:
- 你知道哪几种垃圾回收器,各自的优缺点,重点讲一下cms和G1
- 一面: JVM GC算法有哪些,目前的JDK版本采用什么回收算法
- 一面: G1回收器讲下回收过程
- GC是什么?为什么要有GC?
- 一面: GC的两种判定方法? CMS收集器与G1收集器的特点。
- 百度:
- 说一下GC算法,分代回收说下
- 垃圾收集策略和算法
- 天猫:
- 一面: jvm GC原理,JVM怎么回收内存
- 一面: CMS特点,垃圾回收算法有哪些?各自的优缺点,他们共同的缺点是什么?
- 滴滴:
- 一面: java的垃圾回收器都有哪些,说下G1的应用场景,平时你是如何搭配使用垃圾回收器的
- 京东:
- 你知道哪几种垃圾收集器,各自的优缺点,重点讲下cms和G1,包括原理,流程,优缺点。
- 垃圾回收算法的实现原理。
- 阿里:
- 讲一讲垃圾回收算法。
- 什么情况下触发垃圾回收?
- 如何选择合适的垃圾收集算法?
- JVM有哪三种垃圾回收器?
- 字节跳动:
- 常见的垃圾回收器算法有哪些,各有什么优劣?
- system.gc() 和 runtime.gc() 会做什么事情?
- 一面: Java GC机制? GC Roots有哪些?
- 二面: Java对象的回收方式,回收算法。
- CMS和G1了解么,CMS解决什么问题,说一下回收的过程。
- CMS回收停顿了几次,为什么要停顿两次。
0、其他
1、label
官方:
Refactor the code to remove this label and the need for it.
label标签,不属于关键字,类似于c的goto(很少用),用于标记跳转。底层就是goto语句,尽量不要用
2、怎么学习Java的不同版本的新特性
Java不同版本的新特性:
- 语法层面:Lambda表达式、switch表达式、 自动装箱、自动拆箱、enum关键字、 <>泛式等等
- API层面:Stream API、新的日期时间、Optional、 String、 集合框架
- 底层优化:JVM的优化,GC的变化、元空间、静态域、字符串常量池等
中篇:字节码与类的加载器
1、class文件结构
1、概述
1、字节码文件的跨平台性
Java语言: 跨平台的语言(write once ,run anywhere)
- 当Java源代码成功编译成字节码后,如果想在不同的平台上面运行,则无须再次编译
- 这个优势不再那么吸引人了。Python、 PHP、 Perl、 Ruby、 Lisp等有强大的解释器。
- 跨平台似乎已经快成为一门语言必选的特性。
Java虚拟机:跨语言的平台
Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联。
无论使用何种语言进行软件开发,只要能将源文件编译为正确的Class文件,那么这种语言就可以在Java虚拟机上执行。
可以说,统一而强大的Class文件结构,就是Java虚拟机的基石、桥梁。
所有的JVM全部遵守Java虚拟机规范,也就是说所有的JVM环境都是一样的,这样一来字节码文件可以在各种JVM上运行。
想要让一个Java程序正确地运行在JVM中, Java源码就必须要被编译为符合JVM规范的字节码。
前端编译器的主要任务就是负责将符合Java语法规范的Java代码转换为符合JVM规范的字节码文件。
javac一种能够将Java源码编译为字节码的前端编译器。
javac编译器在将Java源码编译为个有效的字节码文件过程中经历了4个步骤,分别是词法解析、语法解析、语义解析以及生成字节码。
Oracle的JDK软件包括两部分内容:
- 一部分是将Java源代码编译成Java虚拟机的指令集的编译器
- 另一部分是用于实现Java虛拟机的运行时环境
2、Java的前端编译器
1、关于前端编译器与后台编译器在程序编译过程中的作用
2、前端编译器vs后端编译器
Java源代码的编译结果是字节码,那么肯定需要有一种编译器能够将Java源码编译为字节码,承担这个重要责任的就是配置在path环境变量中的javac编译器。javac是一种能够将Java源码编译为字节码的前端编译器。
HotSpotVM并没有强制要求前端编译器只能使用javac来编译字节码,其实只要编译结果符合JVM规范都可以被JVM所识别即可。在Java的前端编译器领域,除了javac之外, 还有一种被大家经常用到的前端编译器,那就是内置在Eclipse中的ECJ (Eclipse Compiler for Java )编译器。和Javac的全量式编译不同,ECI是一种增量式编译器。
- 在Eclipse中,当开发人员编写完代码后,使用“Ctrl+S”快捷键时,ECI编译器所采取的编译方案是把未编译部分的源码逐行进行编译,而非每次都全量编译。因此ECI的编译效率会比javac更加迅速和高效,当然编译质量和javac相比大致还是一样的。
- ECI不仅是Eclipse的默认内置前端编译器,在Tomcat中同样也是使用ECJ编译器来编译jsp文件。由于ECJ编译器是采用GPLv2的开源协议进行源代码公开,所以,大家可以登录eclipse官网下载ECJ编译器的源码进行二次开发。
- 默认情况下,IntelliJ IDEA使用javac编译器。(还可以自己设置为AspectJ编译器 ajc)
前端编译器并不会直接涉及编译优化等方面的技术,而是将这些具体优化细节移交给HotSpot的JIT编译器(后端编译器)负责。
复习:
- JIT(及时编译器)
- AOT(静态提前编译器,Ahead of Time Compiler)
3、透过字节码指令看代码细节
BAT面试题:
- 类文件结构有几个部分?
- 知道字节码吗?字节码都有哪些? Integer x = 5;int y = 5;比较x == y都经过哪些步骤?
代码举例1:
1 | public class IntegerTest { |
相关字节码1:
代码举例2:
1 | public class StringTest { |
相关字节码2:
\
代码举例3:
相关说明:
- 成员变量(非静态的)的赋值过程:
- 默认初始化(注意这里会先调用所有父类的构造方法(super))
- 显式初始化 / 代码块中初始化
- 构造器中初始化
- 有了对象之后,可以”对象.属性”或”对象.方法”的方式对成员变量进行赋值。
相关字节码3:
对son:
代码的执行过程:
- 执行
Father f = new Son();
- 先初始化父类的构造器,在父类的构造器当中调用了print()方法
- 又因为Son重写了父类Father的print()方法,此时又没有到执行显示初始化的步骤(看上面的字节码文件)
- 因此打印的是
Son.x = 0
- 接着初始化Son本身的构造器,Son本身的构造器当中调用了print()方法
- 此时已经经历了显示初始化,x被赋予值30(具体看上面的成员变量(非静态的)的赋值过程)
- 因此打印的是
Son.x = 30
- 最后执行
System.out.println(f.x);
- **
因为属性不存在多态性!!!
**变量f的声明类型是Father,所以它是Father类型的,不是Son类型。 - 所以f.x中的x也是Father的x,因此打印的才是
20
2、虚拟机的基石:class文件
字节码文件里是什么?
源代码经过编译器编译之后便会生成一个字节码文件,字节码是一种二进制的类文件,它的内容是JVM的指令,而不像C、C++经由编译器直接生成机器码。
什么是字节码指令(byte code)?
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand)所构成。虚拟机中许多指令并不包含操作数,只有一个操作码。比如:操作码 (操作数)
如何解读供虚拟机解释执行的二进制字节码?
- 方式一:一个一个二进制的看。这里用到的是Notepad++,需要安装一个HEX-Editor插件,或者使用Binary Viewer
- 方式二:使用javap指令:jdk 自带的反解析工具。eg:javap -v IntegerTest.class >IntegerTest.txt
- 方式三:使用IDEA插件:jclasslib或jclasslib bytecode viewer客户端工具。(可视化更好)
3、class文件结构
1、关于Class文件
相关的官方文档
Class类的本质
- 任何一个Class文件都对应着唯一个类或接口的定义信息,但反过来说,Class文件实际上它并不一定以磁盘文件的形式存在。
- Class 文件是一组以8位字节为基础单位的二进制流。(该二进制流可以来自于磁盘,也可以来自于网络)
Class文件格式
- Class的结构不像XML等描述语言,由于它没有任何分隔符号。所以在其中的数据项,无论是字节顺序还是数量,都是被严格限
定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。 - 例子:
- “下雨天留客天留我不留”:
- “下雨天,留客天,留我不留?”
- “下雨天,留客天,留我不?留!”
- “下雨,天留客?天留,我不留!”
- 目的:压缩字节码文件
- Class的结构不像XML等描述语言,由于它没有任何分隔符号。所以在其中的数据项,无论是字节顺序还是数量,都是被严格限
Class文件格式采用一种类似于C语言结构体的方式进行数据存储,这种结构中只有两种数据类型:**
无符号数
和表
**。- 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
- 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。由于表没有固定长度,所以通常会在其前面加上个数说明。
相关代码举例:
1
2
3
4
5
6
7
8
9
10
11/**
* 全类名:com.atguigu.java1.Demo
* 全限定名:com/atguigu/java1/Demo
*/
public class Demo {
private int num = 1;
public int add(){
num = num + 2;
return num;
}
}经过javac编译过后的16进制的Class文件:
经过插件——jclasslib反编译后的Class文件:
换句话说,充分理解了每一个字节码文件的细节,自己也可以反编译出Java源文件来。
Class文件结构概述:Class文件的结构并不是一成不变的,随着Java虛拟机的不断发展,总是不可避免地会对Class文件结构做出一些调整,但是其基本结构和框架是非常稳定的。
Class文件的总体结构如下:
- 魔数
- u4 magic;
- Class文件版本
- u2 minor_version;
- u2 major_version;
- 常量池
- u2 constant_pool_count;
- cp_info constant_pool[constant_pool_count-1];
- 访问标志
- u2 access_flags;
- 类索引,父类索引,接口索引集合
- u2 this_class;
- u2 super_class;
- u2 interfaces_count;
- u2 interfaces[interfaces_count];
- 字段表集合
- u2 fields_count;
- field_info fields[fields_count];
- 方法表集合
- u2 methods_count;
- method_info methods[methods_count];
- 属性表集合
- u2 attributes_count;
- attribute_info attributes[attributes_count];
The
ClassFile
Structure:(来自官网)这是一张Java字节码总的结构表,我们按照上面的顺序逐一进行解读就可以了:
类型 名称 说明 长度 数量 u4 magic 魔数,识别Class文件格式 4个字节 1 u2 minor_version 副版本号(小版本) 2个字节 1 u2 major_version 主版本号(大版本) 2个字节 1 u2 constant_pool_count 常量池计数器 2个字节 1 cp_info constant_pool 常量池表 n个字节 constant_pool_count-1 u2 access_flags 访问标识 2个字节 1 u2 this_class 类索引 2个字节 1 u2 super_class 父类索引 2个字节 1 u2 interfaces_count 接口计数器 2个字节 1 u2 interfaces 接口索引集合 2个字节 interfaces_count u2 fields_count 字段计数器 2个字节 1 field_info fields 字段表 n个字节 fields_count u2 methods_count 方法计数器 2个字节 1 method_info methods 方法表 n个字节 methods_count u2 attributes_count 属性计数器 2个字节 1 attribute_info attributes 属性表 n个字节 attributes_count - 魔数
2、01-魔数:Class文件的标志
**Magic Number (魔数)**:
每个Class文件开头的4个字节的无符号整数称为魔数(Magic Number)
它的唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的Class文件。即:魔数是Class文件的标识符。
魔数值固定为**
0xCAFEBABE
**。不会改变。如果一个Class文件不以0xCAFEBABE开头,虚拟机在进行文件校验的时候就会直接抛出以下错误:
1
2Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.ClassFormatError:Incompatible magic value 1885430635 in class file StringTest使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。
其实魔数不只是在class文件当中有所应用。在图片.png、音乐.mp3等等,里面也有前几位作为魔数,作为进行对应文件的标识符
3、02-Class文件版本号
紧接着魔数的4个字节存储的是Class 文件的版本号。同样也是4个字节。第5个和第6个字节所代表的含义就是编译的副版本号minor_ version, 而第7个和第8个字节就是编译的主版本号major_ version。
它们共同构成了class文件的格式版本号。譬如某个 Class文件 的主版本号为 M,副版本号为 m,那么这个 Class文件 的格式版本号就确定为 M.m。
版本号和Java编译器的对应关系如下表:
主版本(十进制) 副版本(十进制) 编译器版本 45 3 1.1 46 0 1.2 47 0 1.3 48 0 1.4 49 0 1.5 50 0 1.6 51 0 1.7 52(十六进制对应的是34) 0 1.8 53 0 1.9 54 0 1.10 55 0 1.11 Java的版本号是从**
45
**开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1。虚拟机JDK版本为1.k (k >= 2) 时,对应的class文件格式版本号的范围为45.0 - 44+k.0 (含两端) 。
不同版本的Java编译器编译的Class文件对应的版本是不一样的。目前,高版本的Java虚拟机可以执行由低版本编译器生成的Class文件,但是低版本的Java虛拟机不能执行由高版本编译器生成的Class文件。否则JVM会抛出java.lang.UnsupportedClassVersionError异常。( 向下兼容)
在实际应用中,由于开发环境和生产环境的不同,可能会导致该问题的发生。因此,需要我们在开发时,特别注意开发编译的JDK版本和生产环境中的JDK版本是否一致。
总结成一句话就是:高版本的虚拟机可以解释运行低版本的字节码文件。
4、03-常量池:存放所有常量
1、常量池概述
常量池是Class文件中内容最为丰富的区域之一。常量池对于Class文件中的字段和方法解析也有着至关重要的作用。
随着Java虚拟机的不断发展,常量池的内容也日渐丰富。可以说,常量池是整个Class文件的基石。
官方文档:
在版本号之后,紧跟着的是常量池的数量,以及若干个常量池表项。
常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的无符号数,代表常量池容量计数值(constant_ pool_ count) 。与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的。
类型 名称 数量 u2 constant_pool_count 1 cp_info constant_pool constant_pool_count-1 由上表可见,Class文件使用了一个前置的容量计数器(constant_pool_count) 加若干个连续的数据项(constant_pool) 的形式来描述常量池内容。我们把这一 系列连续常量池数据称为常量池集合。
常量池表项中,用于存放编译时期生成的各种**
字面量
和符号引用
,这部分内容将在类加载后进入方法区的运行时常量池**中存放。(其中字符串常量池在jdk7以后被移进堆空间中)
2、常量池计数器(constant_pool_ count)
由于常量池的数量不固定,时长时短,所以需要放置两个字节来表示常量池容量计数值。
常量池容量计数值(u2类型) :从1开始,表示常量池中有多少项常量。即
constant_pool_count=1
表示常量池中有0个常量项Demo的值为:
其值为0x0016,转换为十进制,也就是22。
需要注意的是,这实际上只有21项常量。索引为范围是1-21。为什么呢?
- 通常我们写代码时都是从0开始的,但是这里的常量池却是从1开始,因为它把第0项常量空出来了。
- 这是为了满足后面某些指向常星池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可用索引值0来表示。
3、常量池表
constant_ pool是一种表结构, 以1 ~ constant_ pool_ count - 1为索引。表明了后面有多少个常量项。
常量池主要存放两大类常量:
**字面量(Literal)**
和**符号引用(Symbolic References )**
它包含了class文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其他常量。常量池中的每一项都具备相同的特征。第1个字节作为类型标记,用于确定该项的格式,这个字节称为tag byte(标记字节、标签字节)。
tag byte与对应的类型:(最后三个是在jdk7添加的,体现了java对动态语言的支持)
类型 标志(或标识) 描述 CONSTANT_utf8_info 1 UTF-8编码的字符串 CONSTANT_Integer_info 3 整型字面量 CONSTANT_Float_info 4 浮点型字面量 CONSTANT_Long_info 5 长整型字面量 CONSTANT_Double_info 6 双精度浮点型字面量 CONSTANT_Class_info 7 类或接口的符号引用 CONSTANT_String_info 8 字符串类型字面量 CONSTANT_Fieldref_info 9 字段的符号引用 CONSTANT_Methodref_info 10 类中方法的符号引用 CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用 CONSTANT_NameAndType_info 12 字段或方法的符号引用 CONSTANT_MethodHandle_info 15 表示方法句柄 CONSTANT_MethodType_info 16 标志方法类型 CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点
4、常量池表——字面量和符号引用
字面量和符号引用:
常量池主要存放两大类常量:
- 字面量(Literal)
- 符号引用(Symbolic References) 。
如下表:
常量 具体的常量 举例 字面量 文本字符串 String str = “Hello”; 声明为final的常星值 final int NUM = 10; 符号引用 类和接口的全限定名 com/atguigu/test/Demo; 字段的名称和描述符 add、num 方法的名称和描述符
全限定名
- com/atguigu/test/Demo这个就是类的全限定名,仅仅是**把包名的”.”替换成”/“**,
- 为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个”;”表示全限定名结束。
简单名称
- 简单名称是指没有类型和参数修饰的方法或者字段名称,上面例子中的类的add()方法和num字段的简单名称分别是add和num。
描述符
描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)**和返回值**。
根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean) 以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示,详见下表:(基本数据类型long对应的是J;基本数据类型boolean对应的是Z;几个[表示几维数组)
标志符 含义 B 基本数据类型byte C 基本数据类型char D 基本数据类型double F 基本数据类型float I 基本数据类型int J 基本数据类型long S 基本数据类型short Z 基本数据类型boolean V 代表void类型 L 对象类型,比如: Ljava/lang/Object;
[ 数组类型,代表一维数组。比如: double[][][] is [[[D
用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号”()”之内。
- 如方法java.lang.String toString()的描述符为:() Ljava/lang/String;
- 方法int abc(int[] x, int y)的描述符为([II) I
补充说明:
- 虚拟机在加载Class文件时才会进行动态链接,也就是说,Class 文件中不会保存各个方法和字段的最终内存布局信息,因此,这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池中获得对应的符号引用,再在
类加载过程中的解析阶段
将其替换为直接引用,并翻译到具体的内存地址中。 - 这里说明下符号引用和直接引用的区别与关联:
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
- 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。 如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。
- 加载前类的方法等信息只是字符串(字面量与符号引用),而加载后会把这个字符串(字面量与符号引用)替换成相对应的内存地址。
- 虚拟机在加载Class文件时才会进行动态链接,也就是说,Class 文件中不会保存各个方法和字段的最终内存布局信息,因此,这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池中获得对应的符号引用,再在
5、常量池表——常量类型和结构
常量池中每一项常量都是一个表,JDK1.7之后共有14种不同的表结构数据。如下表格所示:
常量类型和结构细节:
- 根据上图每个类型的描述我们也可以知道每个类型是用来描述常量池中哪些内容(主要是字面量、符号引用)的。比如:CONSTANT_Integer_info是用来描述常量池中字面量信息的,而且只是整型字面量信息。
- 标志为15、16、18的常量项类型是用来支持动态语言调用的(jdk1.7时才加入的)。
- 细节说明:
- CONSTANT_Class_info:结构用于表示类或接口
- CONSTANT_Fieldref_info、CONSTANT_Methodref_info和CONSTANT_InterfaceMethodref_info结构:表示字段、方法和接口方法
- CONSTANT_string_info结构用于表示String类型的常量对象
- CONSTANT_Integer_info和CONSTANT_Float_info:表示4字节(int 和float)的数值常量
- CONSTANT_Long_info和ICONSTANT Double_info结构:表示8字节(long和double)的数值常量
- 在class文件的常量池表中,所有的8字节常量均占两个表成员(项)的空间。如果一个CONSTANT_Long_info或CONSTANT_Double_info结构的项在常量池表中的索引位n,则常量池表中下一个可用项的索引位n+2,此时常量池表中索引为n+1的项仍然有效但必须视为不可用的。
- CONSTANT_NameAndType_info结构用于表示字段或方法,但是和之前的3个结构不同,CONSTANT_NameAndType_info结构没有指明该字段或方法所属的类或接口。
- CONSTANT_utf8_info用于表示字符常量的值
- CONSTANT_MethodHandle_info结构用于表示方法句柄
- CONSTANT_MethodType_info结构:表示方法类型
- CONSTANT_InvokeDynamic_info结构用于表示invokedynamic指令所用到的引导方法(bootstrap method)、 引导方法所用到的动态调用名(dynamic invocation name)、 参数和返回类型,并可以给引导方法传入一系列称为静态参数(static argument)的常量。
- 解析方式:
- 一个字节一个字节的解析
- 使用javap命令解析:javap -verbose Demo.class 或 jclasslib工具会更方便。
- 总结1:
- 这14种表(或者常量项结构)的共同点是:表开始的第一位是一个u1类型的标志位(tag),代表当前这个常量项使用的是哪种表结构,即哪种常量类型。
- 在常量池列表中,CONSTANT_Utf8_info常量项是一种使用改进过的UTF - 8编码格式来存储诸如文字字符串、类或者接口的全限定名、字段或者方法的简单名称以及描述符等常量字符串信息。
- 这14种常量项结构还有一个特点是,其中13个常量项占用的字节固定,只有CONSTANT_Utf8_info占用字节不固定,其大小由length决定。为什么呢?
- 因为从常量池存放的内容可知,其存放的是字面量和符号引用,最终这些内容都会是一个字符串,这些字符串的大小是在编写程序时才确定,比如你定义一个类, 类名可以取长取短,所以在没编译前,大小不固定,编译后,通过utf-8编码, 就可以知道其长度。
- 总结2:
- 常量池:可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型(后面的很多数据类型都会指向此处),也是占用Class文件空间最大的数据项目之一。
- 常量池中为什么要包含这些内容:
- Java代码在进行Javac编译的时候, 并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态链接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。关于类的创建和动态链接的内容,在虚拟机类加载过程时再进行详细讲解。
5、04-访问标识
访问标识(access_ flag、访问标志、访问标记)
在常量池后,紧跟着访问标记。该标记使用两个字节表示,用于识别一些类或者接口层次的访问信息,包括:
- 这个Class是类还是接口;
- 是否定义为public类型;
- 是否定义为abstract类型;如果是类的话,是否被声明为final等。
各种访问标记如下所示:
标志名称 标志值 含义 ACC_PUBLIC 0x0001 标志为public类型 ACC_FINAL 0x0010 标志被声明为final,只有类可以设置 ACC_SUPER 0x0020 标志允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真。(使用增强的方法调用父类方法) ACC_INTERFACE 0x0200 标志这是一个接口 ACC_ABSTRACT 0x0400 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 ACC_SYNTHETIC 0x1000 标志此类并非由用户代码产生(即:由编译器产生的类,没有源码对应) ACC_ANNOTATION 0x2000 标志这是一个注解 ACC_ENUM 0x4000 标志这是一个枚举 类的访问权限通常为ACC_ 开头的常量。
每一种类型的表示都是通过设置访问标记的32位中的特定位来实现的。比如,若是public final的类,则该标记为ACC_PUBLIC | ACC_FINAL。
使用ACC_SUPER可以让类更准确地定位到父类的方法super.method(),现代编译器都会设置并且使用这个标记。
补充说明:
- 带有ACC_INTERFACE标志的class文件表示的是接口而不是类,反之则表示的是类而不是接口。
- 如果一个class文件被设置了ACC_INTERFACE 标志,那么同时也得设置ACC_ABSTRACT 标志。同时它不能再设置ACC_FINAL、ACC_SUPER或ACC_ENUM标志。
- 如果没有设置ACC_INTERFACE标志,那么这个class文件可以具有上表中除ACC_ANNOTATION外的其他所有标志。当然,ACC_FINAL和ACC_ ABSTRACT这类互斥的标志除外。这两个标志不得同时设置。
- ACC_SUPER标志用于确定类或接口里面的invokespecial指令使用的是哪一种执行语义。针对Java虚拟机指令集的编译器都应当设置这个标志。对于Java SE 8及后续版本来说,无论class文件中这个标志的实际值是什么,也不管class文件的版本号是多少,Java虛拟机都认为每个class文件均设置了ACC_SUPER标志。
- ACC_SUPER标志是为了向后兼容由旧Java编译器所编译的代码而设计的。目前的ACC_SUPER标志在由JDK 1.0.2之前的编译器所生成的access_flags中是没有确定含义的,如果设置了该标志,那么Oracle的Java虛拟机实现会将其忽略。
- ACC_SYNTHETIC标志意味着该类或接口是由编译器生成的,而不是由源代码生成的。
- 注解类型必须设置ACC_ANNOTATION标志。如果设置了ACC_ANNOTATION标志,那么也必须设置ACC_ INTERFACE标志。
- ACC_ENUM标志表明该类或其父类为枚举类型。
- 带有ACC_INTERFACE标志的class文件表示的是接口而不是类,反之则表示的是类而不是接口。
6、05-类索引、父类索引、接口索引集合
在访问标记后,会指定该类的类别、父类类别以及实现的接口,格式如下:
长度 含义 u2 this_class u2 super_class u2 interfaces_count u2 interfaces[interfaces_count] 这三项数据来确定这个类的继承关系:
- 类索引用于确定这个类的全限定名
- 父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。
- 接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends 语句)后的接口顺序从左到右排列在接口索引集合中。
- this_class (类索引)
- 2字节无符号整数,指向常量池的索引。它提供了类的全限定名, 如com/atguigu/java1/Demo。this_ class的值必须是对常量池表中某项的一个有效索引值。常量池在这个索引处的成员必须为CONSTANT_Class_info类型结构体,该结构体表示这个class文件所定义的类或接口。
- super_class (父类索引)
- 2字节无符号整数,指向常量池的索引。它提供了当前类的父类的全限定名。如果我们没有继承任何类,其默认继承的是java/lang/Object类。同时,由于Java不支持多继承,所以其父类只有一个。
- superclass指向的父类不能是final。
- interfaces
- 指向常量池索引集合,它提供了一个符号引用到所有己实现的接口
- 由于一个类可以实现多个接口,因此需要以数组形式保存多个接口的索引,表示接口的每个索引也是一个指向常量池的CONSTANT_Class ( 当然这里就必须是接口,而不是类)。
- interfaces_count (接口计数器):interfaces_ count 项的值表示当前类或接口的直接超接口数量。
- interfaces [](接口索引集合):
interfaces []中每个成员的值必须是对常量池表中某项的有效索引值,它的长度为interfaces_count。每个成员interfaces[i]必须为CONSTANT_Class_ info结构, 其中0 <= i < interfaces_count。 在interfaces []中,各成员所表示的接口顺序和对应的源代码中给定的接口顺序(从左至右)一样,即interfaces [0]对应的是源代码中最左边的接口。
7、06-字段表集合
1、字段表集合
fields:
- 用于描述接口或类中声明的变量。字段(field)包括类级变量以及实例级变量,但是不包括方法内部、代码块内部声明的局部变量。(local variables)
- 字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。
- 它指向常量池索引集合,它描述了每个字段的完整信息。比如**字段的标识符、访问修饰符(public、private或protected)、是类变量还是实例变量(static修饰符)、是否是常量(final修饰符)**等。
注意事项:
- 字段表集合中不会列出从父类或者实现的接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段。譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
- 在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称, 但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的。
2、fields_ count(字段计数器)
- fields_count的值表示当前class文件fields表的成员个数。 使用两个字节来表示。
- fields表中每个成员都是一个field_info结构, 用于表示该类或接口所声明的所有类字段或者实例字段,不包括方法内部声明的变量,也不包括从父类或父接口继承的那些字段。
3、fields [](字段表)
fields表中的每个成员都必须是个fields_info结构的数据项,用于表示当前类或接口中某个字段的完整描述。
一个字段的信息包括如下这些信息。这些信息中,各个修饰符都是布尔值,要么有,要么没有。
- 作用域(public、private、protected修饰符)
- 是实例变量还是类变量(static修饰符)
- 可变性(final)
- 并发可见性(volatile修饰符,是否强制从主内存读写)
- 可否序列化(transient修饰符)
- 字段数据类型(基本数据类型、对象、数组)
- 字段名称
字段表作为一个表,同样有他自己的结构:
类型 名称 含义 数量 u2 access_flags 访问标志 1 u2 name_index 字段名索引 1 u2 descriptor_index 描述符索引 1 u2 attributes_count 属性计数器 1 attribute_info attributes 属性集合 attributes_count 字段表访问标识
我们知道,一 个字段可以被各种关键字去修饰,比如:作用域修饰符(public、private、protected) 、static修饰符 、final修饰符、volatile修饰符等等。因此,其可像类的访问标志那样,使用一些标志来标记字段。字段的访问标志有如下这些:
标志名称 标志值 含义 ACC_PUBLIC 0x0001 标志为public类型 ACC_FINAL 0x0010 标志被声明为final,只有类可以设置 ACC_SUPER 0x0020 标志允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真。(使用增强的方法调用父类方法) ACC_INTERFACE 0x0200 标志这是一个接口 ACC_ABSTRACT 0x0400 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 ACC_SYNTHETIC 0x1000 标志此类并非由用户代码产生(即:由编译器产生的类,没有源码对应) ACC_ANNOTATION 0x2000 标志这是一个注解 ACC_ENUM 0x4000 标志这是一个枚举
字段名索引:根据字段名索引的值,查询常量池中的指定索引项即可。
描述符索引
描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte, char, double, float,int,long, short , boolean)及代表无返回值的void类型都用一个大写字符来表示, 而对象则用字符L加对象的全限定名来表示,如下所示:
标志符 含义 B 基本数据类型byte C 基本数据类型char D 基本数据类型double F 基本数据类型float I 基本数据类型int J 基本数据类型long S 基本数据类型short Z 基本数据类型boolean V 代表void类型 L 对象类型,比如: Ljava/lang/Object;
[ 数组类型,代表一维数组。比如: double[][][] is [[[D
属性表集合
一个字段还可能拥有一些属性,用于存储更多的额外信息。比如初始化值、一些注释信息等。属性个数存放在attribute_count中,属性具体内容存放在attributes数组中。
例子:以常量属性为例,结构为:
1
2
3
4
5ConstantValue_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 constantvalue_index;
}说明:对于常量属性而言,attribute_length值恒为2。
根据上面的例子,我们来实际分析一下,如下图:
8、07-方法表集合
1、方法表集合
methods:指向常量池索引集合,它完整描述了每个方法的签名。
- 在字节码文件中,每一个method_info项都对应着个类或者接口中的方法信息。比如方法的访问修饰符(public、private或protected),方法的返回值类型以及方法的参数信息等。
- 如果这个方法不是抽象的或者不是native的,那么字节码中会体现出来。
- 一方面,methods表只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法。另一方面,methods表有可能会出现由编译器自动添加的方法,最典型的便是编译器产生的方法信息(比如:类(接口)初始化方法
()**和 实例初始化方法()**)。
使用注意事项:
- 在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名之中,因此Java语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。
- 但在Class文件格式中, 特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个class文件中。
- 也就是说,尽管Java语法规范并不允许在一个类或者接口中声明多个方法签名相同的方法,但是和Java语法规范相反,字节码文件中却恰恰允许存放多个方法签名相同的方法,唯一的条件就是这些方法之间的返回值不能相同。
2、methods_ count(方法计数器)
- methods_count的值表示当前class文件methods表的成员个数。使用两个字节来表示。
- methods表中每个成员都是一个method_info结构
3、methods [] (方法表)
methods表中的每个成员都必须是一个method_info结构, 用于表示当前类或接口中某个方法的完整描述。如果某个method_info结构的access_flags项既没有设置ACC_NATIVE标志,也没有设置ACC_ABSTRACT标志,那么该结构中也应包含实现这个方法所用的Java虚拟机指令。
method_info结构可以表示类和接口中定义的所有方法,包括实例方法、类方法、实例初始化方法和类或接口初始化方法
方法表的结构实际跟字段表是一样的,方法表结构如下:
类型 名称 含义 数量 u2 access_flags 访问标志 1 u2 name_index 字段名索引 1 u2 descriptor_index 描述符索引 1 u2 attributes_count 属性计数器 1 attribute_info attributes 属性集合 attributes_count 方法表访问标志
跟字段表一样,方法表也有访问标志,而且他们的标志有部分相同,部分则不同,方法表的具体访问标志如下:
标记名 值 说明 ACC_PUBLIC 0x0001 public,方法可以从包外访问 ACC_PRIVATE 0x0002 private,方法只能本类中访问 ACC_PROTECTED 0x0004 protected,方法在自身和子类可以访问 ACC_STATIC 0x0008 static,静态方法
9、08-属性表集合
1、属性表集合(attributes)
- 方法表集合之后的属性表集合,指的是class 文件所携带的辅助信息,比如该class 文件的源文件的名称。以及任何带有RetentionPolicy.CLASS或者RetentionPolicy.RUNTIME的注解。这类信息通常被用于Java虚拟机的验证和运行,以及Java程序的调试,一般无须深入了解。
- 此外,字段表、方法表都可以有自己的属性表。用于描述某些场景专有的信息。
- 属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但Java虛拟机运行时会忽略掉它不认识的属性。
2、attributes_ count(属性计数器)
- attributes_count的值表示当前class文件属性表的成员个数。属性表中每一项都是个attribute_info结构。
3、attributes [] ( 属性表)
属性表的每个项的值必须是attribute_ info结构。属性表的结构比较灵活,各种不同的属性只要满足以下结构即可。
属性的通用格式:(即只需说明属性的名称以及占用位数的长度即可,属性表具体的结构可以去自定义)
类型 名称 数量 含义 u2 attribute_name_index 1 属性名索引 u4 attribute_length 1 属性长度 u1 info attribute_length 属性表 属性类型:
属性表实际上可以有很多类型,上面看到的Code属性只是其中一种,Java8里面定义了23种属性。下面这些是虚拟机中预定义的属性:
属性名称 使用位置 含义 Code 方法表 Java代码编译成的字节码指令 ConstantValue 字段表 final关键字定义的常量池 Deprecated 类,方法,字段表 被声明为deprecated的方法和字段 Exceptions 方法表 方法抛出的异常 EnclosingMethod 类文件 仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法 InnerClass 类文件 内部类列表 LineNumberTable Code属性 Java源码的行号与字节码指令的对应关系 LocalVariableTable Code属性 方法的局部变量描述 StackMapTable Code属性 JDK1.6中新增的属性.供新的类型检查检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配 Signature 类,方法,字段表 用于支持泛型情况下的方法签名 Sourcefile 类文件 记录源文件名称 SourceDebugExtension 类文件 用于存储额外的调试信息 Synthetic 类,方法,字段表 标志方法或字段为编译器自动生成的 LocalVariableTypeTable 类 使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加 RuntimeVisibleAnnotations 类,方法,字段表 为动态注解提供支持 RuntimeInvisibleAnnotations 类,方法,字段表 用于指明哪些注解是运行时不可见的 RuntimeVisibleParameterAnnotation 方法表 作用与RuntimeVisibleAnnotations属性类似,只不过作用对象为方法 RuntimeInvisibleParameterAnnotation 方法表 作用与RuntimeInvisibleAnnotations属性类似,作用对象哪个为方法参数 AnnotationDefault 方法表 用于记录注解类元素的默认值 BootstrapMethods 类文件 用于保存invokeddynamic指令引用的引导方式限定符 或者(查看官网):
部分属性详解
ConstantValue 属性:
ConstantValue 属性表示一个常量字段的值。位于field_info结构的属性表中。
1
2
3
4
5
6ConstantValue_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 constantvalue_index;//字段值在常量池中的索引,常量池在该索引处的项给出该属性表示的常量值。
//(例如,值是1ong型的, 在常量池中便是CONSTANT_ Long )
}Deprecated 属性:
Deprecated属性是在JDK 1.1为了支持注释中的关键词@deprecated而引入的。
1
2
3
4Deprecated_attribute {
u2 attribute name_index;
u4 attribute_length;
}Code 属性:
Code属性就是存放方法体里面的代码。但是,并非所有方法表都有Code属性。像接口或者抽象方法,他们没有具体的方法体,因此也就不会有Code属性了。
Code属性表的结构,如下图:
类型 名称 数量 含义 u2 attribute_name_index 1 属性名索引 u4 attribute_length 1 属性长度 u2 max_stack 1 操作数栈深度的最大值 u2 max_locals 1 局部变量表所需的存续空间 u4 code_length 1 字节码指令的长度 u1 code code_length 存储字节码指令 u2 exception_table_length 1 异常表长度 exception_info exception_table exception_length 异常表 u2 attribute_count 1 属性集合计数器 attribute_info attributes attribute_count 属性集合 可以看到:Code属性表的前两项跟属性表是-致的,即Code属性表遵循属性表的结构,后面那些则是他自定义的结构。
InnerClasses 属性:
- 为了方便说明特别定义一个表示类或接口的Class格式为C。如果C的常量池中包含某个CONSTANT_Class_info成员,且这个成员所表示的类或接口不属于任何一个包,那么C的ClassFile结构的属性表中就必须含有对应的InnerClasses属性。
- InnerClasses属性是在JDK 1.1中为了支持内部类和内部接口而引入的,位于ClassFile结构的属性表。
LineNumberTable 属性:
LineNumberTable属性是可选变长属性,位于Code结构的属性表。
LineNumberTable属性是用来描述Java源码行号与字节码行号之间的对应关系。这个属性可以用来在调试的时候定位代码执行的行数。
- start_pc,即字节码行号;
- line_number,即Java源代码行号。
在Code属性的属性表中,LineNumberTable属性可以按照任意顺序出现,此外,多个LineNumberTable属性可以共同表示一个行号在源文件中表示的内容, 即LineNumberTable 属性不需要与源文件的行一对应。
LineNumberTable属性表结构:
1
2
3
4
5
6
7
8
9LineNumberTable_attribute{
u2 attribute_name_index:
u4 attribute_length;
u2 line_number_table_length;
{
u2 start_pc;
u2 line_number;
} line_number_table[line_number_table_length];
}
LocalVariableTable 属性:
LocalVariableTable 是可选变长属性,位于Code属性的属性表中。它被调试器用于确定方法在执行过程中局部变量的信息。
在Code属性的属性表中,LocalVariableTable 属性可以按照任意顺序出现。
Code属性中的每个局部变量最多只能有一个LocalVariableTable 属性。
- startpc + length表示这个变量在字节码中的生命周期起始和结束的偏移位置(this生命周期从头0到结尾10)
- index就是这个变量在局部变量表中的槽位(槽位可复用)
- name就是变量名称
- Descriptor表示局部变量类型描述
LocalVariableTable属性表结构:
1
2
3
4
5
6
7
8
9
10
11
12LocalVariableTable_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 local_variable_table_length;
{
u2 start_pc;
u2 length;
u2 name_index;
u2 descriptor_index;
u2 index;
} local_variable_table[local_variable_table_length] ;
}
Signature 属性
- Signature属性是可选的定长属性,位于ClassFile,field_info或method_info结构的属性表中。
- 在Java语言中,任何类、接口、 初始化方法或成员的泛型签名如果包含了类型变量(Type Variables) 或参数化类型 ( Parameterized Types) ,则Signature 属性会为它记录泛型签名信息。
SourceFile 属性
SourceFile属性结构:
类型 名称 数量 含义 u2 attribute_name_index 1 属性名索引 u4 attribute_length 1 属性长度 u2 sourcefile_index 1 源码文件索引 可以看到,其长度总是固定的8个字节。
其他属性:
- Java虚拟机中预定义的属性有20多个,这里就不一一介绍了,通过上面几个属性的介绍,只要领会其精髓,其他属性的解读也是易如反掌。
10、class字节码文件总结
- 主要介绍了Class文件的基本格式。
- 随着Java平台的不断发展,在将来,Class文件的内容也一定会做进一步的扩充,但是其基本的格式和结构不会做重大调整。
- 从Java虚拟机的角度看,通过Class文件,可以让更多的计算机语言支持Java虚拟机平台。因此,Class文件结构不仅仅是Java虛拟机的执行入口,更是Java生态圈的基础和核心。
4、使用javap指令解析Class文件
1、解析字节码的作用
- 自己分析类文件结构太麻烦了! oracle提供了javap工具。
- javap是jdk自带的反解析工具。它的作用就是根据class字节码文件,反解析出当前类对应的code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。
- 通过反编译生成的汇编代码,我们可以深入的了解java代码的工作机制。比如我们可以查看i++;这行代码实际运行时是先获取变量i的值,然后将这个值加1,最后再将加1后的值赋值给变量i。
- 通过局部变量表,我们可以查看局部变量的作用域范围、所在槽位等信息,甚至可以看到槽位复用等信息。
2、javac -g操作
- 解析字节码文件得到的信息中,有些信息(如局部变量表、指令和代码行偏移量映射表、常量池中方法的参数名称等等)需要在使用javac编译成class文件时,指定参数才能输出。
- 比如,你直接javac xx. java, 就不会在生成对应的局部变量表等信息,如果你使用**
javac -g xx.java
**就可以生成所有相关信息了。如果你使用的eclipse或IDEA,则默认情况下,eclipse、 IDEA在编译时会帮你生成局部变量表、指令和代码行偏移量映射表等信息的。
3、javap的用法
javap的用法格式:javap
- 其中,options就是需要输入的参数选项
- classes就是你要反编译的class文件。
在命令行中直接输入javap或javap -help可以看到javap的options有如下选项:
相关代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35public class JavapTest {
// 五大字段:private、默认、public、protected、final
private int num;
boolean flag;
protected char gender;
public String info;
public static final int COUNTS = 1;
// 静态代码块
static{
String url = "www.atguigu.com";
}
// 非静态代码块
{
info = "java";
}
// 两种构造方法:无参(public)与有参(private)
public JavapTest(){
}
private JavapTest(boolean flag){
this.flag = flag;
}
// 四大方法:private、默认、public、protected
private void methodPrivate(){
}
int getNum(int i){
return num + i;
}
protected char showGender(){
return gender;
}
public void showInfo(){
int i = 10;
System.out.println(info + i);
}
}这里重组一下:
-help / –help / -?:输出此用法消息
-version:版本信息,其实是当前javap所在jdk的版本信息,不是class在哪个jdk下生成的。(即javap -version == javap -version xx.class)
以上的options与相关class文件无关,为javap本身的options
-public:仅显示公共类和成员
-protected:显示受保护的/公共类和成员
-p / -private:显示所有类和成员
package:显示程序包/受保护的/公共类和成员(默认)
-sysinfo:显示正在处理的类的系统信息(路径,大小,日期,MD5散列,源文件名)
constants:显示静态最终常量
以下options与代码的细节相关:
-s:输出内部类型签名
-l:输出行号和本地变量表
注意:如果使用的是javac xx. java编译生成的class字节码文件,里面本来就没有本地变量表。因此就是使用-l也看不到本地变量表的信息。
-c:对代码进行反汇编
-v / -verbose:输出附加信息(包括行号、本地变量表,反汇编等详细信息)
注意:就算-v也依旧没有私有private的信息。如果需要加上私有private的信息,得到一份最全的信息,就需要加上-p:
1
javap -v -p JavapTest.class
以下很少用到,了解即可。
-classpath
:指定查找用户类文件的位置 -cp
:指定查找用户类文件的位置
总结:
- 一般常用的是-v、-l、-c三个选项:(重要的还是-v和-p)
- javap -l:会输出行号和本地变量表信息。
- javap -c:会对当前class字节码进行反编译生成汇编代码。
- javap -v classxx:除了包含-c内容外,还会输出行号、局部变量表信息、常量池等信息。
- 一般常用的是-v、-l、-c三个选项:(重要的还是-v和-p)
4、总结
- 通过javap命令可以查看一个java类反汇编得到的Class文件版本号、常量池、访问标识、变量表、指令代码行号表等等信息。不显示类索引、父类索引、接口索引集合、
()、 。其中()等结构 ()、 ()这两个是因为javap太智能,帮我们反编译成了相关的构造方法和静态代码块,在class字节码文件依旧可以看到这两个结构。 - 通过对前面例子代码反汇编文件的简单分析,可以发现,一个方法的执行通常会涉及下面几块内存的操作:
- java栈中:局部变量表、操作数栈。
- java堆:通过对象的地址引用去操作。
- 常量池。
- 其他如帧数据区(方法返回地址、动态链接、一些附加信息)、方法区的剩余部分等情况,测试中没有显示出来,这里说明一下。
- 平常,我们比较关注的是java类中每个方法的反汇编中的指令操作过程,这些指令都是顺序执行的,可以参考官方文档查看每个指令的含义。
2、字节码指令集与解析举例
1、概述
- Java字节码对于虚拟机,就好像汇编语言对于计算机,属于基本执行指令。
- Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为**
操作码,Opcode
) 以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands
**) 而构成。由于Java虚拟机采用面向操作数栈而不是寄存器的结构,所以大多数的指令都不包含操作数,只有一个操作码。 - 由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不可能超过256条。
- 官方文档
- 熟悉虚拟机的指令对于动态字节码生成、反编译Class文件、Class文件修补都有着非常重要的价值。因此,阅读字节码作为了解Java虚拟机的基础技能,需要熟练掌握常见指令。
1、执行模型
如果不考虑异常处理的话,那么Java虚拟机的解释器可以使用下面这个伪代码当做最基本的执行模型来理解:
1 | do{ |
2、字节码与数据类型
- 在Java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。
- 对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:
- i代表对int类型的数据操作
- l代表long
- s代表short
- b代表byte
- c代表char
- f代表float
- d代表double
- Boolean使用的是iconst_0或者iconst1
- a代表对象
- 也有一些指令的助记符中没有明确地指明操作类型的字母,如arraylength指令, 它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。
- 还有另外一些指令,如无条件跳转指令goto则是与数据类型无关的。
- 大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期将byte和short类型的数据带符号扩展(Sign-Extend) 为相应的int类型数据,将boolean和char类型数据零位扩展(Zero-Extend) 为相应的int类型数据。
- 与之类似,在处理boolean、byte、 short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,大多数对于boolean、 byte、 short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型。
3、指令分析
- 由于完全介绍和学习这些指令需要花费大量时间。为了让大家能够更快地熟悉和了解这些基本指令,这里将JVM中的字节码指令集按用途大致分成9类。
- 加载与存储指令
- 算术指令
- 类型转换指令
- 对象的创建与访问指令
- 方法调用与返回指令
- 操作数栈管理指令
- 比较控制指令
- 异常处理指令
- 同步控制指令
- (说在前面)在做值相关操作时:
- 一个指令,可以从局部变量表、常量池、堆中对象、方法调用、系统调用中等取得数据,这些数据(可能是值可能是对象的引用)被压入操作数栈。
- 一个指令,也可以从操作数栈中取出一到多个值(pop多次),完成赋值、加减乘除、方法传参、系统调用等等操作。
2、加载与存储指令
- 作用:加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递。
- 其中数据压入操作数栈被称为加载
- 此时的数据可能来源于局部变量表,也有可能来自于常量池(分成具体的两个类指令)
- 把数据保存在局部变量表当中则被称为存储指令
- 常用指令
- [局部变量压栈指令]将一个局部变量加载到操作数栈:xload、xload_
(其中x为i、l、f、d、a,n为0到3(不一定都是0~3,需要具体分析))(load) - [常量入栈指令]将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_W、ldc2_W、aconst_null、iconst_ m1、iconst_ 、lconst_
、fconst_ 、 dconst_ 。(push、ldc、const) - [出栈装入局部变量表指令]将一个数值从操作数栈存储到局部变量表:xstore、xstore_
(其中x为i、l、f、d、a,n为0到3);xastore ( 其中x为i、l、f、d、a、b、c、s)(store) - 扩充局部变量表的访问索引的指令:wide。
- [局部变量压栈指令]将一个局部变量加载到操作数栈:xload、xload_
- 上面所列举的指令助记符中,有一部分是以尖括号结尾的 (例如iload_
)。这些指令助记符实际上代表了一组指令(例如 iload_ 代表了iload_0、iload_1、iload_2和iload_3这几个指令) 。这几组指令都是某个带有一个操作数的通用指令(例如iload)的特殊形式,对于这若干组特殊指令来说,它们表面上没有操作数,不需要进行取操作数的动作,但操作数都隐含在指令中。 - 比如:
- iload_0:将局部变量表中索引为0位置上的数据压入操作数栈中。(占一个字节)
- iload 0:将局部变量表中索引为0位置上的数据压入操作数栈中。(占三个字节)
- iload 4:将局部变量表中索引为4位置上的数据压入操作数栈中。(占三个字节)
- 作用:节约空间,减少内存占用
- 除此之外,它们的语义与原生的通用指令完全一致(例如iload_0的语义与操作数为0时的iload指令语义完全一致)。在尖括号之间的字母指定了指令隐含操作数的数据类型,
代表非负的整数, 代表是int类型数据, 代表1ong类型, 代表float类型, 代表double类型。 - 操作byte、char、short 和boolean类型数据时,经常用int类型的指令来表示。
0、复习:再谈操作数栈与局部变量表
1、操作数栈(Operand Stacks )
我们知道,Java字节码是Java虛拟机所使用的指令集。因此,它与Java虚拟机基于栈的计算模型是密不可分的。
在解释执行过程中,每当为Java方法分配栈桢时,Java虚拟机往往需要开辟块额外的空间作为操作数栈, 来存放计算的操作数以及返回结果。
具体来说便是:执行每一条指令之前,Java虚拟机要求该指令的操作数已被压入操作数栈中。在执行指令时,Java虚拟机会将该指令所需的操作数弹出,并且将指令的结果重新压入栈中。
例子:
以加法指令iadd 为例。假设在执行该指令前,栈顶的两个元素分别为int值1和int值2,那么iadd指令将弹出这两个int, 并将求得的和int值3压入栈中。
由于iadd指令只消耗栈项的两个元素,因此,对于离栈顶距离为2的元素,即图中的问号,iadd 指令并不关心它是否存在,更加不会对其进行修改。
2、局部变量表(Local Variables)
Java方法栈桢的另外一个重要组成部分则是局部变量区,字节码程序可以将计算的结果缓存在局部变量区之中。
实际上,Java虚拟机将局部变量区当成一个数组,依次存放this指针(仅非静态方法) ,所传入的参数, 以及字节码中的局部变量。
和操作数栈一样,long类型以及double类型的值将占据两个单元,其余类型仅占据一个单元。一个槽位就是一个单元,占4个字节。
举例:
代码:
1 | public void foo(long 1, float f) { |
对应的图示:(槽位复用)
1、局部变量压栈指令
局部变量压栈指令将给定的局部变量表中的数据压入操作数栈。
这类指令大体可以分为:
- xload_
(x为i、l、f、d、a,n为0到3) - xload (x为i、l、f、d、a)
说明:在这里,x的取值表示数据类型。
指令xload_ n表示将第n个局部变量压入操作数栈,比如iload_1、 fload_0、aload_0等指令。其中aload_n表示将一个对象引用压栈。此时该字节码指令占用一个字节(包含一个操作码,一个字节)
指令xload通过指定参数的形式,把局部变量压入操作数栈,当使用这个命令时,表示局部变量的数量可能超过了4个,比如指令iload、fload等。此时该字节码指令占用三个字节(包含一个操作码,一个字节、一个操作数,两个字节)
举例:
代码:
1 | //1.局部变量压栈指令 |
字节码指令执行过程:
2、常量入栈指令
常量入栈指令的功能是将常数压入操作数栈,根据数据类型和入栈内容的不同,又可以分为const系列、push系列和ldc指令。
指令const系列:用于对特定的常量入栈,入栈的常量隐含在指令本身里。
指令有: iconst_ <i> (i从-1到5)
、lconst_ <l> (l从0到1)
、fconst_ <f> (f从0到2)
、dconst_ <d> (d从0到1)
、aconst_null
。
比如:
- iconst_m1将-1压入操作数栈;
- iconst_x (x为0到5)将x压入栈;
- lconst_0、lconst_1分别将长整数0和1压入栈;
- fconst_0、fconst_1、 fconst_2分别将浮点数0、1、2压入栈;
- dconst_0和dconst_1分别将double型0和1压入栈;
- aconst_null将null压入操作数栈;
从指令的命名上不难找出规律,指令助记符的第一个字符总是喜欢表示数据类型,i表示整数
,l表示长整数
,f表示浮点数
,d表示双精度浮点
,习惯上用a表示对象引用
。如果指令隐含操作的参数,会以下划线形式给出。
指令push系列:主要包括bipush和sipush。 它们的区别在于接收数据类型的不同,bipush接收8位整数作为参数,sipush接收16位整数,它们都将参数压入栈。
指令ldc系列:如果以上指令都不能满足需求,那么可以使用万能的ldc指令,它可以接收一个8位的参数,该参数指向常量池中的int、float或者String的索引,将指定的内容压入堆栈。
类似的还有ldc_w
,它接收两个8位参数,能支持的索引范围大于ldc。
如果要压入的元素是long或者double类型的,则使用ldc2_w
指令,使用方式都是类似的。
总结如下:
举例分析:
注意:常量入栈指令中的n和局部变量压栈指令中的n不一样,常量入栈的n代表数值或者对象,而不是局部变量表中的下标
3、出栈入局部变量表指令
出栈装入局部变量表指令用于将操作数栈中栈项元素弹出后,装入局部变量表的指定位置,用于给局部变量赋值。
这类指令主要以store
的形式存在,比如xstore(x为i、l、f、d、a)、xstore_n(x为i、l、f、d、a,n为0至3)。
- 其中,指令istore_n将从操作数栈中弹出一个整数,并把它赋值给局部变量索引n的位置。
- 指令xstore由于没有隐含参数信息,故需要提供一个byte类型的参数类指定目标局部变量表的位置。
说明:
一般说来,类似像store这样的命令需要带一个参数,用来指明将弹出的元素放在局部变量表的第几个位置。 但是,为了尽可能压缩指令大小,使用专门的istore_1指令表示将弹出的元素放置在局部变量表第1个位置。类似的还有istore_0、istore_2、 istore_3,它们分别表示从操作数栈顶弹出一个元素,存放在局部变量表第0、2、3个位置。
由于局部变量表前几个位置总是非常常用,因此这种做法虽然增加了指令数量,但是可以大大压缩生成的字节码的体积。如果局部变量表很大,需要存储的槽位大于3,那么可以使用istore指令,外加一个参数,用来表示需要存放的槽位位置。
举例分析:
相关分析:
- 首先该方法被调用的时候,形式参数k和d都是有确定的值,由于该方法不是静态方法,所以局部变量表中的第一个位置(槽位)存储this,而第二个位置存储k具体的值,由于老师只是分析,没有调用这个方法,所以老师全部使用的变量名称来代替具体的值,所以明白就好。
- 然后第三个和第四个位置储存d具体的值,由于d是double类型,所以需要占据两个槽位,数据已经准备好了,那就来看字节码
- 首先iload_1是将局部变量表中下标为1的k值取出来压入操作数栈中
- 然后iconst_2是将常量池中的整型值2压入操作数栈,iadd让操作数栈弹出的k值和整型值2执行相加操作
- 之后将相加的结果值m压入操作数栈中,请注意老师的画法,在执行弹栈和压栈操作之后,老师并没有删除操作数栈中的k值和2,这是因为老师让我们知道具体的操作过程,所以故意为之,不过真正的操作是弹栈之后k值和2就会从操作数栈中弹出,之后操作数栈中就没有k值和2了,只有m值了
- 然后istore_4是将操作数栈中的m值弹出栈,然后放在局部变量表中下标为4的位置
- idc2_w #13代表将long型值12压入操作数栈,istore5是将值12弹栈之后放入局部变量表中下标为5的位置,由于12是long型,所以占据两个位置(槽位)
- ldc #15代表将字符串atguigu压入操作数栈,astore 7代表将字符串atguigu弹栈之后放入局部变量表中下标为7的位置
- idc #16代表将float类型数据10.0压入操作数栈,fstore 8代表将10.0弹出栈,然后放入局部变量表中下标为8的位置
- idc2_w #17代表将10.0压入操作数栈,dstore2代表将10.0弹出栈,之后将10.0放入下标为2和3的操作,毕竟这是double类型数据。
槽位复用:
注意:在方法没有运行的时候,根据字节码文件就可以计算出需要几个槽位。
3、算术指令
作用:算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新压入操作数栈。
分类:
- 大体上算术指令可以分为两种:
- 对整型数据进行运算的指令
- 对浮点类型数据进行运算的指令
- 大体上算术指令可以分为两种:
byte、short、char和boolean类型说明:
在每一大类中, 都有针对Java虚拟机具体数据类型的专用算术指令。但没有直接支持byte、short、char和boolean类型的算术指令,对于这些数据的运算,都使用int类型的指令来处理。此外,在处理boolean、byte、 short 和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。
运算时的溢出:
数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能是一个负数。其实Java虚拟机规范并无明确规定过整型数据溢出的具体结果,仅规定了在处理整型数据时,只有除法指令以及求余指令中当出现除数为0时会导致虚拟机抛出异常ArithmeticException
。运算模式:
- 向最接近数舍入模式:JVM要求在进行浮点数计算时,所有的运算结果都必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值一样接近, 将优先选择最低有效位为零的;(四舍五入)
- 向零舍入模式:将浮点数转换为整数时,采用该模式,该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果。(截断法)
NaN值使用:
当一个操作产生溢出时,将会使用有符号的无穷大(
Infinity
)表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN
值来表示。而且所有使用NaN值作为操作数的算术操作,结果都会返回NaN。对于无穷大Infinity和NaN的举例:
1、所有算术指令
所有的算术指令包括:
- 加法指令:iadd、ladd、fadd、dadd
- 减法指令:isub、lsub、fsub、dsub
- 乘法指令:imul、lmul、fmul、dmul
- 除法指令:idiv、ldiv、fdiv、ddiv
- 求余指令:irem、lrem、frem、drem //remainder:余数
- 取反指令:ineg、lneg、fneg、dneg //negation:取反
- 自增指令:iinc
- 位运算指令, 又可分为:
- 位移指令:ishl、 ishr、iushr、lshl、lshr、lushr
- 按位或指令:ior、lor
- 按位与指令:iand、land
- 按位异或指令:ixor、lxor
- 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
相关举例的分析:
对于 i = i + 10 与 i += 10 的区别:(假设i = 100)
i = i + 10:
i += 10:
结论:
- 若i一开始为byte类型,与10相加之后转换为int类型;自增10之后编译不报错
- 如果short i = 10,那么i+=10不是在原位置上加10,而是进行了强转,其中用到了i2s
JVM取反(~)操作的具体实现过程:(用异或实现)
- 先取出操作数压入操作数栈
- 在将-1压入操作数栈(iconst_m1)
- 将操作数与-1实现异或(xor)操作
- 得到的操作数取反后的值在压入操作数栈
1、举例
1 | public static int bar(int i) { |
字节码指令对应的图示:
2、一个曾经的案例
代码:
1 | public static void main(String[] args) { |
字节码对应的内存解析:(省略了前面的解析,主要看一下println()方法:返回值为void)
注意:
- 执行System.out.printin()的时候,会在虚拟机栈当中新建一个System.out.printin方法栈帧,55作为参数传入println()方法当中;
- println()方法栈帧的本地变量表中会放55,这样该方法就可以使用了。
- println()方法执行结束之后,由于返回值为void,所以字节码指令直接return
- 进入main方法栈帧之后,也直接执行return。
3、关于i++与++i
在没有其他操作(如赋值)的情况下,i++与++i是一样的,没有区别:
1 | //关于(前)++和(后)++ |
字节码:
与其他运算符(赋值运算符)结合运算的情况下,i++与++i就有区别了:
- i++:先赋值后自增
- ++i:先自增后赋值
1 | public void method7(){ |
字节码:
与println()方法结合的情况:
1 | public void method8(){ |
字节码:
2、比较指令的说明
- 比较指令的作用是比较栈顶两个元素的大小,并将比较结果入栈。
- 比较指令有:dcmpg、dcmpl、 fcmpg、 fcmpl、 lcmp。
- 与前面讲解的指令类似,首字符d表示double类型, f表示float , l表示long。
- 对于double和float类型的数字,由于NaN的存在,各有两个版本的比较指令。以float为例,有fcmpg和fcmpl两个指令,它们的区别在于在数字比较时,若遇到NaN值,处理结果不同。
- 指令dcmpl和dcmpg也是类似的,根据其命名可以推测其含义,在此不再赘述。
- 指令lcmp针对long型整数,由于long型整数没有NaN值,故无需准备两套指令。
举例:
- 指令fcmpg和fcmpl都从栈中弹出两个操作数,并将它们做比较,设栈顶的元素为v2,栈顶顺位第2位的元素为v1
- 若v1 = v2,则压入0;
- 若v1 > v2,则压入1;
- 若v1 < v2,则压入-1
- 两个指令的不同之处在于,如果遇到NaN值,fcmpg会压入1,而fcmpl会压入-1。
数值类型的数据,才可以谈大小! boolean、引用数据类型不能比较大小。
注意:
- NaN(Not a Number)表示不是一个数字,比如0.0/0.0得到的可能是1.0(两个数相等),也可能是0.0(0.0是分子),也可能是无穷大(0.0是分母),所以老师给出的解释是NaN代表无法确定是什么数字,只有double和float类型中可能出现NaN的情况,而long类型不会出现NaN,所以只有lcmp。
- 为什么只存在long/float/double的比较指令,而没有char/byte/short/int类型的比较指令?
- 仔细观察会发现long/float/double的比较指令的最后都会往操作数栈压入一个int类型的值(-1/1/0),而比较指令常常与跳转指令一起使用
- 而两种指令是通过int类型的值结合在一起使用的
- 所以也就不必要存在int等类型的比较指令
- 如果说是有int类型的比较指令的话就是比较条件跳转指令了
4、类型转换指令
类型转换指令说明:
- 类型转换指令可以将两种不同的数值类型进行相互转换。
- 这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
1、宽化类型转换(Widening Numeric Conversions)
1、转换规则
Java虚拟机直接支持以下数值的宽化类型转换(widening numeric conversion,小范围类型向大范围类型的安全转换)。也就是说,并不需要指令执行,包括:
- 从int类型到long、float或者double类型。对应的指令为:
i2l
、i2f
、i2d
- 从long类型到float、double类型。对应的指令为:
l2f
、l2d
- 从float类型到double类型。对应的指令为:
f2d
简化为:int –> long –> float –> double
2、精度损失问题
- 宽化类型转换是不会因为超过目标类型最大值而丢失信息的,例如,从int转换到long,或者从int转换到double,都不会丢失任何信息,转换前后的值是精确相等的。
- 从int、long类型数值转换到float,或者long类型数值转换到double时,将可能发生精度丢失——可能丢失掉几个最低有效位上的值,转换后的浮点数值是根据IEEE754最接近舍入模式所得到的正确整数值。
尽管宽化类型转换实际上是可能发生精度丢失的,但是这种转换永远不会导致Java虚拟机抛出运行时异常。
3、补充说明
- 从byte、char 和short类型到int类型的宽化类型转换实际上是不存在的。对于byte类型转为int,虚拟机并没有做实质性的转化处理,只是简单地通过操作数栈交换了两个数据。而将byte转为long时,使用的是
i2l
,可以看到在内部byte在这里已经等同于int类型处理,类似的还有short类型,这种处理方式有两个特点:- 一方面可以减少实际的数据类型,如果为short和byte都准备一套指令,那么指令的数量就会大增,而虛拟机目前的设计上,只愿意使用一个字节表示指令,因此指令总数不能超过256个,为了节省指令资源,将short和byte当做int处理也在情理之中。
- 另一方面,由于局部变量表中的槽位固定为32位(4个字节,也是int的长度),无论是byte或者short存入局部变量表,都会占用32位空间。从这个角度说,也没有必要特意区分这几种数据类型。
2、窄化类型转换(Narrowing Numeric Conversion)
1、转换规则
Java虚拟机也直接支持以下窄化类型转换:
- 从int类型至byte、short或者char类型。对应的指令有:
i2b
、i2c
、i2s
- 从long类型到int类型。对应的指令有:
l2i
- 从float类型到int或者long类型。对应的指令有:
f2i
、f2l
- 从double类型到int、long或者float类型。 对应的指令有:
d2i
、d2l
、d2f
注意:从float、double、long等类型往byte、short、char类型转换的时候,需要先把前面几种类型转换成int类型,然后在从int类型转换到后面这几种类型,所以int类型相等于一种过渡类型。
2、精度损失问题
- 窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此,转换过程很可能会导致数值丢失精度。
- 尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是Java虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。
3、补充说明
- 当将一个浮点值窄化转换为整数类型T(T限于int或long类型之一)的时候,将遵循以下转换规则:
- 如果浮点值是NaN,那转换结果就是int或long类型的0。
- 如果浮点值不是无穷大的话,浮点值使用IEEE 754的向零舍入模式取整,获得整数值v,如果v在目标类型T(int或long)的表示范围之内,那转换结果就是v。否则,将根据v的符号,转换为T所能表示的最大或者最小正数。
- 当将一个double类型窄化转换为float类型时,将遵循以下转换规则:
- 通过向最接近数舍入模式舍入一个可以使用float类型表示的数字。最后结果根据下面这3条规则判断:
- 如果转换结果的绝对值太小而无法使用float来表示, 将返回float类型的正负零。
- 如果转换结果的绝对值太大而无法使用float来表示,将返回float类型的正负无穷大。
- 对于double类型的NaN值将按规定转换为float类型的NaN值。
- 通过向最接近数舍入模式舍入一个可以使用float类型表示的数字。最后结果根据下面这3条规则判断:
举例:
代码:
1 | public void downCast5(){ |
5、对象的创建与访问指令
Java是面向对象的程序设计语言,虚拟机平台从字节码层面就对面向对象做了深层次的支持。有一系列指令专门用于对象操作,可进一步细分为创建指令、字段访问指令、数组操作指令、类型检查指令。
1、创建指令
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令:
创建类实例的指令:
创建类实例的指令:new
- 它接收一个操作数,为指向常量池的索引,表示要创建的类型,执行完成后,将对象的引用压入栈。
例子:
创建数组的指令:
创建数组的指令:newarray、 anewarray、 multianewarray
- newarray:创建基本类型数组
- anewarray:创建引用类型数组
- multianewarray:创建多维数组
例子:
上述创建指令可以用于创建对象或者数组,由于对象和数组在Java中的广泛使用,这些指令的使用频率也非常高。
2、字段访问指令
对象创建后,就可以通过对象访问指令获取对象实例或数组实例中的字段或者数组元素。
- 访问类字段(static字段,或者称为类变量)的指令:
getstatic
、putstatic
- 访问类实例字段(非static字段,或者称为实例变量)的指令:
getfield
、putfield
举例1:
以getstatic指令为例,它含有一个操作数,为指向常量池的Fieldref索引,它的作用就是获取Fieldref指定的对象或者值,并将其压入操作数栈。
1 | public void sayHello() { |
对应的字节码指令:
1 | 0 getstatic #8 <java/lang/System.out> |
图示:
举例2:
代码:
1 | public void setOrderId(){ |
字节码指令执行过程:
注意:getxxx是入栈,而putxxx是出栈
3、数组操作指令
1、数组操作指令
数组操作指令主要有:xastore
和xaload
指令。具体为:
- 把一个数组元素加载到操作数栈的指令:
baload
、caload
、saload
、iaload
、laload
、faload
、daload
、aaload
- 将一个操作数栈的值存储到数组元素中的指令:
bastore
、castore
、sastore
、iastore
、lastore
、fastore
、dastore
、aastore
即:
- 取数组长度的指令:
arraylength
- 该指令弹出栈顶的数组元素,获取数组的长度,将长度压入栈。
2、说明
一个操作数栈的值存储到数组元素中的指令,即xastore指令与xstore指令的区别:
- xstore指令是将值存放进局部变量表里面
- xastore指令是将值存放进堆空间中对应的数组元素里面
指令xaload表示将数组的元素压栈,比如saload、caload分别表示压入short数组和char数组。指令xaload在执行时,要求操作数中栈顶元素为数组索引i,栈顶顺位第2个元素为数组引用a,该指令会弹出栈顶这两个元素,并将a[i]重新压入栈。
xastore则专门针对数组操作,以iastore为例, 它用于给一个int数组的给定索引赋值。在iastore执行 ,操作数栈顶需要以此准备3个元素:
- 值
- 索引
- 数组引用
iastore会弹出这3个值,并将值赋给数组中指定索引的位置。
4、类型检查指令
检查类实例或数组类型的指令:instanceof
、 checkcast
- 指令
checkcast
用于检查类型强制转换是否可以进行。如果可以进行,那么checkcast指令不会改变操作数栈,否则它会抛出ClassCastException
异常。 - 指令
instanceof
用来判断给定对象是否是某一个类的实例,它会将判断结果压入操作数栈。
6、方法调用与返回指令
1、方法调用指令
方法调用指令:invokevirtual
、invokeinterface
、invokespecial
、invokestatic
、invokedynamic
以下5条指令用于方法调用:
invokevirtual
指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),支持多态。(可被子类重写)这也是Java语言中最常见的方法分派方式。- invokevirtual是调用类中的非静态普通方法,而这种实例方法可能调用的是子类重写的非静态普通方法,比如A a = new B();a.hello(),其中B类继承A类,并且B类重写了A类中的hello()方法,这种情况下就是invokevirtual了,但是有可能该类没有子类,调用的就是本类中的非静态普通方法,这种情况也是invokevirtual了
invokeinterface
指令用于调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用。- invokeinterface是对接口而言的,用属于接口类型的对象调用方法的时候就是这个
invokespecial
指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(构造器)**、私有方法和父类方法。这些方法都是静态类型绑定的,不会在调用时进行动态派发。(不能被子类重写**)- invokespecial只有构造器、私有方法、super.方法名()调用父类方法这几种情况,其中调用父类方法这种情况可能出现其直接父类没有该方法,那就可以调用其父类继承的父类中的该方法,最终找到一个方法调用就是了。
invokestatic
指令用于调用命名类中的类方法(static方法)。这是静态绑定的。- invokestatic是调用static静态方法,无论是使用对象.静态方法名()还是类名.静态方法名()都是invokestatic,也不难理解
invokedynamic
:调用动态绑定的方法,这个是JDK 1.7后新加入的指令。用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。前面4条调用指令的分派逻辑都固化在java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。具体可看博客:关于invokedynamic
注意:
- 当一个方法既是私有方法,又是静态方法。使用的是invokestatic指令
- 关于在接口当中的静态方法与默认方法
- 静态方法:使用的是invokestatic指令
- 默认方法:使用的是invokeinterface指令
总结:
- 除了static的接口的方法都是invokeinterface,如果是static那么都是invokestatic。
- 用static修饰的方法都是使用invokestatic
- 如果是用多态的话使用的是invokevirtual
- 如果是强转成接口类型的方法使用的是invokeinterface
2、方法返回指令
方法调用结束前,需要进行返回。方法返回指令是根据返回值的类型区分的:
- 包括
ireturn
(当返回值是boolean、 byte、char、short和int类型时使用)、lreturn
、freturn
、dreturn
和areturn
- 另外还有一条
return
指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。
说明:
- 通过ireturn指令,将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所有在当前函数操作数栈中的其他元素都会被丢弃。
- 如果当前返回的是synchronized方法,那么还会执行一个隐含的
monitorexit
指令,退出临界区(解锁的作用)。最后,会丢弃当前方法的整个帧,恢复调用者的帧,并将控制权转交给调用者。 - 当返回的变量与返回值不是同一个基本数据类型的话,会有一个使用类型转换指令的过程。
举例:
代码:
1 | public int methodReturn( ){ |
7、操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,JVM提供的操作数栈管理指令,可以用于直接操作操作数栈的指令。
这类指令包括如下内容:
- 将一个或两个元素从栈顶弹出,并且直接废弃:
pop
,pop2
; - 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈项:
dup
,dup2
,dup_x1
,dup2_x1
,dup_x2
,dup2_x2
; - 将栈最顶端的两个Slot数值位置交换:
swap
。- Java虛拟机没有提供交换两个64位数据类型(long、double) 数值的指令。
- 指令
nop
,是一个非常特殊的指令,它的字节码为0x00。和汇编语言中的nop一样,它表示什么都不做。这条指令一般可用于调试、占位等。
这些指令属于通用型,对栈的压入或者弹出无需指明数据类型。
说明:
- 不带_x的指令是复制栈顶数据并压入栈顶。包括两个指令,
dup
和dup2
。- dup的系数代表要复制的Slot个数。
- dup开头的指令用于复制1个Slot的数据。
- 例如1个int或1个reference类型数据
- dup2开头的指令用于复制2个Slot的数据。
- 例如1个long,或2个int,或1个int+1个float类型数据
- 带_ x的指令是复制栈顶数据并
插入
栈顶以下的某个位置。共有4个指令,dup_x1
,dup2_x1
,dup_x2
,dup2_x2
。- 对于带x的复制插入指令,只要将指令的dup和x的系数相加,结果即为需要插入的位置。因此:
- dup_x1插入位置:1+1=2, 即栈顶2个Slot下面
- dup_x2插入位置:1+2=3, 即栈顶3个Slot下面
- dup2_x1插入位置:2+1=3, 即栈顶3个Slot下面
- dup2_x2插入位置:2+2=4, 即栈顶4个Slot下面
- 对于带x的复制插入指令,只要将指令的dup和x的系数相加,结果即为需要插入的位置。因此:
pop
:将栈项的1个Slot数值出栈。例如1个short类型数值pop2
:将栈顶的2个Slot数值出栈。例如1个double类型数值,或者2个int类型数值
例子:
8、控制转义指令
程序流程离不开条件控制,为了支持条件跳转,虚拟机提供了大量字节码指令,大体上可以分为:
- 比较指令(在算术指令那里)
- 条件跳转指令
- 比较条件跳转指令
- 多条件分支跳转指令
- 无条件跳转指令等。
1、条件跳转指令
条件跳转指令通常和比较指令结合使用。在条件跳转指令执行前。一般可以先用比较指令进行栈顶元素的准备,然后进行条件跳转。
条件跳转指令有:ifeq
,iflt
,ifle
,ifne
,ifgt
,ifge
, ifnull
, ifnonnull
。
这些指令都接收两个字节的操作数,用于计算跳转的位置(16位符号整数作为当前位置的offset)。
它们的统一含义为:弹出栈顶元素,测试它是否满足某一条件,如果满足条件,则跳转到给定位置。
具体说明:
注意:
- 与前面运算规则一致:
- 对于boolean、byte、char、short类型的条件分支比较操作,都是使用int类型的比较指令完成
- 对于long、float、double类型的条件分支比较操作,则会先执行相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转。
- 由于各类型的比较最终都会转为int类型的比较操作,所以Java虚拟机提供的int类型的条件分支指令是最为丰富和强大的。
例子:
注意:
- 对于float、double、long类型的比较,它们比较之后生成的是int类型的0、1、-1,这个过程可以使用比较指令和条件跳转指令来完成,虽然得到的是int类型的值,但是System.out.println(XXX)中的值是布尔类型,你可以在jclasslib中的常量池信息中看到写的是Z,代表布尔值类型。
- int类型值(包含byte、char、short)比较和对象类型值比较需要使用比较条件跳转指令。
- 在比较当中,跳转指令的选择与代码里面的条件判断恰好相反。
- 如题当中代码比较的是f1 < f2,而字节码当中却使用了
ifge指令
(f1 >= f2) - 原因是该指令是跳转指令:也就是当满足条件才跳转,不满足的话就只是顺序执行。所以与代码执行顺序相反(代码是满足条件就顺序执行,不满足才跳转到相应的执行语句)
- 如题当中代码比较的是f1 < f2,而字节码当中却使用了
2、比较条件跳转指令
比较条件跳转指令类似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一。
这类指令有:if_icmpeq
、if_icmpne
、if_icmplt
、if_icmpgt
、if_icmple
、if_icmpge
、(之前的都是与int相关的比较条件跳转指令)if_acmpeq
和if_acmpne
。其中指令助记符加上”if”后,以字符”i”开头的指令针对int型整数操作(也包括short和byte类型), 以字符”a”开头的指令表示对象引用的比较。
具体说明:
这些指令都接收两个字节的操作数作为参数,用于计算跳转的位置。同时在执行指令时,栈顶需要准备两个元素进行比较。
指令执行完成后,栈顶的这两个元素被清空,且没有任何数据入栈。如果预设条件成立,则执行跳转,否则,继续执行下一条语句。
注意:
- 上面所说的后者是栈顶元素,而前者是栈顶下面的元素
- 对于float、double、long类型的比较,它们比较之后生成的是int类型的0、1、-1,这个过程可以使用比较指令和条件跳转指令来完成。
- 而int类型值(包含byte、char、short)比较和对象类型值比较需要使用比较条件跳转指令,
- 其中对象类型值不是比较的地址,就是比较对象中的某些字段值,这又归咎到float、double、long、int类型的比较中比较条件跳转指令。
3、多条件分支跳转
多条件分支跳转指令是专为switch-case语句设计的,主要有tableswitch
和lookupswitch
。
从助记符上看,两者都是switch语句的实现,它们的区别:
tableswitch
要求多个条件分支值是连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数index,可以立即定位到跳转偏移量位置,因此效率比较高。lookupswitch
内部存放着各个离散的case-offset对,每次执行都要搜索全部的case-offset对,找到匹配的case值,并根据对应的offset计算跳转地址,因此效率较低。
指令tableswitch:
指令tableswitch的示意图如下图所示。由于tableswitch的case值是连续的,因此只需要记录最低值和最高值,以及每一项对应的offset偏移量,根据给定的index值通过简单的计算即可直接定位到offset。
举例:
注意:代码的break语句对应的就是字节码指令里的goto指令,无条件跳转到return处。如果代码没有加上break语句的话就会发生switch穿透,其实对应到字节码指令就是缺少goto指令跳转到return,只能往下顺序执行。
指令lookupswitch:
指令lookupswitch处理的是离散的case值,但是出于效率考虑,将case-offset对按照case值大小排序,给定index时需要查找与index相等的case,获得其offset,如果找不到则跳转到default。指令lookupswitch如下图所示。
举例:
关于String的switch语句:(使用的是指令lookupswitch和方法hashcode与equal)
4、无条件跳转
目前主要的无条件跳转指令为goto
。
- 指令goto接收两个字节的操作数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处。
- 如果指令偏移量太大,超过双字节的带符号整数的范围,则可以使用指令
goto_W
,它和goto有相同的作用,但是它接收4个字节的操作数,可以表示更大的地址范围。 - 指令
jsr
、jsr_W
、ret
虽然也是无条件跳转的,但主要用于try-finally
语句,且已经被虚拟机逐渐废弃,故不在这里介绍这两个指令。
举例:
通过goto指令与条件比较指令实现循环:
注意:
- 这里使用的i为int类型,在代码执行
i++
的时候,字节码指令使用的是iinc 1 by 1
;直接在局部变量表里面加。 - 如果使用的i为double类型,在代码执行
i++
的时候,字节码使用的是dload_x
+dconst_1
+dadd
+dastore_x
的指令组合实现的,需要在操作数栈中相加。(注意这里dload_x与dastore_x当中的x是一样的) - 如果使用的i为short类型,在代码执行
i++
的时候,字节码使用的是iload_x
+iconst_1
+iadd
+i2s
+istore_x
的指令组合实现的,需要在操作数栈中相加,并且在相加之后还需要将值从int窄化为short类型,才能存进局部变量表当中。 - 所以用于循环遍历的变量尽量使用int,能增加系统的执行速度(调优)
9、异常处理指令
异常及异常的处理:
过程一:异常对象的生成过程 —> throw (手动/自动) —> 指令: athrow
过程二:异常的处理:抓抛模型。try-catch-finally —>使用异常表
1、抛出异常指令
athrow
指令- 在Java程序中显示抛出异常的操作(throw语句)都是由athrow指令来实现。
- 除了使用throw语句显示抛出异常情况之外,JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。
- 例如,在之前介绍的整数运算时,当除数为零时,虚拟机会在
idiv
或ldiv
指令中抛出ArithmeticException异常。
- 例如,在之前介绍的整数运算时,当除数为零时,虚拟机会在
注意:
- 正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时,Java虚拟机会清除操作数栈上的所有内容,而后将异常实例压入调用者操作数栈上。
- 如果使用
throw new 异常名称()
这种形式来抛出异常,那就会在代码中出现athrow
指令, - 而
在方法上面添加throw异常名称
这种形式来抛出异常,然后使用jclasslib的时候就会出现在方法下面多出现一个属性Exceptions。
举例:
throw new 异常名称():
在方法上面添加throw异常名称:
运行时异常没有athrow:
2、异常处理与异常表
- 处理异常:
- 在Java虚拟机中,处理异常(catch语句)**不是由字节码指令来实现的(早期使用jsr、ret指令),而是采用异常表来完成的**。
- 异常表:
- 如果一个方法定义了一个try-catch或者try-finally的异常处理,就会创建一个异常表。它包含了每个异常处理或者finally块的信息。
- 异常表保存了每个异常处理信息。比如:
- 起始位置
- 结束位置
- 程序计数器记录的代码处理的偏移地址
- 被捕获的异常类在常量池中的索引
- 当一个异常被抛出时,JVM会在当前的方法里寻找一个匹配的处理,如果没有找到,这个方法会强制结束并弹出当前栈帧,并且异常会重新抛给上层调用的方法(在调用方法栈帧)。如果在所有栈帧弹出前仍然没有找到合适的异常处理,这个线程将终止。
- 如果这个异常在最后一个非守护线程里抛出,将会导致JVM自己终止,比如这个线程是个main线程。
- 不管什么时候抛出异常,如果异常处理最终匹配了所有异常类型,代码就会继续执行。在这种情况下,如果方法结束后没有抛出异常,仍然执行finally块,在return前,它直接跳到finally块来完成目标。
异常表如下所示:
异常表的含义是如果在Start PC和End PC之间(大于等于Start PC,小于End PC(左闭右开))出现对应的Catch Type异常问题(出现异常就匹配对应的异常),将会在操作数栈中压入相应的异常类对象,之后跳转到Handler PC的位置去执行对应的字节码指令。
注意:
当异常出现的时候也会压入操作数栈,之后还会存储局部变量表中
举例1:
举例2:
10、同步控制指令
组成:
- java虚拟机支持两种同步结构:
- 方法级的同步
- 方法内部一段指令序列的同步
- 这两种同步都是使用
monitor
来支持的。
1、方法级的同步(添加synchronized的方法)
方法级的同步:是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法;
当调用方法时,调用指令将会检查方法的ACC_SYNCHRONIZED访 问标志是否设置。
- 如果设置了,执行线程将先持有同步锁,然后执行方法。最后在方法完成(无论是正常完成还是非正常完成)时释放同步锁。
- 在方法执行期间,执行线程持有了同步锁,其他任何线程都无法再获得同一个锁。
- 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放。
说明:
这段代码和普通的无同步操作的代码没有什么不同,没有使用monitorenter和monitorexit进行同步区控制。这是因为,对于同步方法而言,当虚拟机通过方法的访问标示符判断是一个同步方法时,会自动在方法调用前进行加锁,当同步方法执行完毕后,不管方法是正常结束还是有异常抛出,均会由虚拟机释放这个锁。因此,对于同步方法而言,monitorenter
和monitorexit
指令是隐式存在的,并未直接出现在字节码中。
举例:一个方法无论是否添加synchronized,你都无法在字节码中看出区别
是否是同步方法在字节码文件中你是无法看出区别的,但是可以在方法访问标识中看出区别
2、方法内部一段指令序列的同步
同步一段指令集序列:通常是由java中的synchronized语句块来表示的。jvm的指令集有monitorenter
和monitorexit
两条指令来支持synchronized关键字的语义。
- 当一个线程进入同步代码块时,它使用
monitorenter
指令请求进入。如果当前对象的监视器计数器为0,则它会被准许进入 - 若为1,则判断持有当前监视器的线程是否为自己,如果是,则进入,否则进行等待,直到对象的监视器计数器为0,才会被允许进入同步块。
- 当线程退出同步块时,需要使用
monitorexit
声明退出。在Java虚拟机中,任何对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有后,对象处于锁定状态。 - 指令
monitorenter
和monitorexit
在执行时,都需要在操作数栈项压入对象,之后monitorenter
和monitorexit
的锁定和释放都是针对这个对象的监视器进行的。
下图展示了监视器如何保护临界区代码不同时被多个线程访问,只有当线程4离开临界区后,线程1、2、3才有可能进入。
编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令,而无论这个方法是正常结束还是异常结束。
为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令。
举例:
过程分析:
- 操作数栈中的对象和
monitorenter
结合起来可以让线程获取锁,做法就是让对象的监视器标记从0变成1,这就代表该线程上锁了; - 然后在操作数栈的
aload_1
和monitorexit
结合起来就可以让线程解锁,做法就是让对象的监视器标记从1变成0; - 这个解锁需要在方法退出之前完成,如果方法执行过程中出现了任何异常,将会跳到异常处理的字节码处执行相关代码;
- 如果异常处理的字节码部分出现了问题,那就重新执行异常处理的字节码。
这些内容都在异常表中写的很明确,其中异常表也在上面截图中。
3、类的加载过程详解
1、概述
类的加载过程详解:这里的类指的是Class。泛指java当中的类Class、接口Interface、注解类Annotation、枚举Enum等等。
在Java中数据类型分为基本数据类型和引用数据类型:
- 基本数据类型由虚拟机预先定义
- 引用数据类型则需要进行类的加载
按照Java虛拟机规范,从class文件到加载到内存中的类,到类卸载出内存为止,它的整个生命周期包括如下7个阶段:
其中,验证、准备、解析3个部分统称为链接(Linking)
注意:我们所说的加载完毕包括:加载、链接、初始化三个阶段都完成之后类进入方法区中。
从程序中类的使用过程看:
2、过程一:Loading(加载)阶段
1、加载完成的操作
1、加载的理解
所谓加载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型——类模板对象。
所谓类模板对象,其实就是Java类在JVM内存中的一个快照,JVM将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样JVM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。
反射的机制即基于这一基础。 如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法反射。
2、加载完成的操作
加载阶段,简言之,查找并加载类的二进制数据,生成Class的实例。
在加载类时,Java虚拟机必须完成以下3件事情:
- 通过类的全名,获取类的二进制数据流。
- 解析类的二进制数据流为方法区内的数据结构(Java类模型)
- 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口。
2、二进制流的获取方式
对于类的二进制数据流,虚拟机可以通过多种途径产生或获得:(只要所读取的字节码符合JVM规范即可)
- 虚拟机可能通过文件系统读入一个class后缀的文件(最常见)
- 读入jar、zip等归档数据包,提取类文件。
- 事先存放在数据库中的类的二进制数据
- 使用类似于HTTP之类的协议通过网络进行加载(序列化与反序列化)
- 在运行时生成段Class的二进制信息等
在获取到类的二进制信息后,Java虛拟机就会处理这些数据,并最终转为一个java.lang.Class的实例。
如果输入数据不是ClassFile的结构, 则会抛出ClassFormatError
。
3、类模型与Class实例的位置
类模型的位置:加载的类在JVM中创建相应的类结构,类结构会存储在方法区(JDK1.8之前:永久代;JDK1.8及之后:元空间)。
Class实例的位置:
- 类将.class文件加载至元空间后,会在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构;
- 该Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。(instanceKlass –> mirror:Class的实例)
图示:
说明:
- Class类的构造方法是私有的,只有JVM能够创建。
- java.lang.Class实例是访问类型元数据的接口,也是实现反射的关键数据、入口。
- 通过Class类提供的接口,可以获得目标类所关联的.class文件中具体的数据结构:方法、字段等信息。
4、数组类的加载
- 创建数组类的情况稍微有些特殊,因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的;
- 但数组的元素类型仍然需要依靠类加载器去创建。
- 创建数组类(下述简称A)的过程:
- 如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载和创建数组A的元素类型;
- JVM使用指定的元素类型和数组维度来创建新的数组类。
- 如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定。否则数组类的可访问性将被缺省定义为public。
3、过程二:Linking(链接)阶段
1、环节1:链接阶段之Verification(验证)
当类加载到系统后,就开始链接操作,验证是链接操作的第一步。
它的目的是保证加载的字节码是合法、合理并符合规范的。
验证的步骤比较复杂,实际要验证的项目也很繁多,大体上Java虚拟机需要做以下检查,如图所示:
整体说明:验证的内容则涵盖了类数据信息的格式验证、语义检查、字节码验证,以及符号引用验证等。
- 其中格式验证会和加载阶段一起执行。验证通过之后,类加载器才会成功将类的二进制数据信息加载到方法区中。
- 格式验证之外的验证操作将会在方法区中进行。
链接阶段的验证虽然拖慢了加载速度,但是它避免了在字节码运行时还需要进行各种检查。(磨刀不误砍柴工)
具体说明:
格式验证:
- 是否以魔数0xCAFEBABE开头
- 主版本和副版本号是否在当前Java虚拟机的支持范围内
- 数据中每一个项是否都拥有正确的长度等。
Java虚拟机会进行字节码的语义检查,但凡在语义上不符合规范的,虚拟机也不会给予验证通过。比如:
- 是否所有的类都有父类的存在(在Java里, 除了object外, 其他类都应该有父类)
- 是否一些被定义为final的方法或者类被重写或继承了
- 非抽象类是否实现了所有抽象方法或者接口方法
- 是否存在不兼容的方法。比如:
- 方法的签名除了返回值不同,其他都一样,这种方法会让虚拟机无从下手调度
- abstract情况下的方法,就不能是final的了
Java虚拟机还会进行字节码验证,字节码验证也是验证过程中最为复杂的一个过程。它试图通过对字节码流的分析,判断字节码是否可以被正确地执行。比如:
- 在字节码的执行过程中,是否会跳转到一条不存在的指令
- 函数的调用是否传递了正确类型的参数
- 变量的赋值是不是给了正确的数据类型等
栈映射帧(StackMapTable)**就是在这个阶段,用于检测在特定的字节码处,其局部变量表和操作数栈是否有着正确的数据类型。但遗憾的是,100%准确地判断一段字节码是否可以被安全执行是无法实现的,因此,该过程只是尽可能地检查出可以预知的明显的问题。如果在这个阶段无法通过检查,虛拟机也不会正确装载这个类。但是,如果通过了这个阶段的检查,也不能说明这个类是完全没有问题的。在前面3次检查中,已经排除子文件格式错误、语义错误以及字节码的不正确性。但是依然不能确保类是没有问题的**。
校验器还将进行符号引用的验证。Class文件在其常量池会通过字符串记录自己将要使用的其他类或者方法。因此,在验证阶段,虚拟机就会检查这些类或者方法确实是存在的,并且当前类有权限访问这些数据,如果一个需要使用类无法在系统中找到,则会抛出
NoClassDefFoundError
,如果一个方法无法被找到,则会抛出NoSuchMethodError
。此阶段在解析环节才会执行。
2、环节2:链接阶段之Preparation(准备)
准备阶段(Preparation),简言之,**为类的静态变量
分配内存,并将其初始化为默认值
**。
当一个类验证通过时,虚拟机就会进入准备阶段。在这个阶段,虚拟机就会为这个类分配相应的内存空间,并设置默认初始值。
Java虛拟机为各类型变量默认的初始值如表所示:
类型 | 默认初始值 |
---|---|
byte | (byte)0 |
short | (short)0 |
int | 0 |
long | 0L |
float | 0.0f |
double | 0.0 |
char | \u0000 |
boolean | false |
reference | null |
注意:Java并不支持boolean类型,对于boolean类型,内部实现是int,由于int的默认值是0,故对应的,boolean的默认值就是false。
注意:
- **这里不包含基本数据类型的字段用static final修饰(常量)的情况, 因为final在
编译
的时候就会分配了,准备阶段会显式赋值
**。 - 注意这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中,会在使用类时候才会初始化。
- 在准备阶段并不会像初始化阶段中那样会有初始化或者代码被执行。
对注意中的第1点与第3点分析:
- 基本数据类型:
- 非final修饰的
变量
,在准备环节进行默认初始化赋值
。 - **final修饰以后就是
常量
了,不能在进行赋值,所以在编译阶段
会初始化赋值
,然后在准备阶段
就会显示赋值
**。
- 非final修饰的
- 如果使用字面量的方式定义一个字符串的常量的话(public static final String constStr = “CONST”;),也是在
编译阶段
会初始化赋值
,然后在准备阶段
就会显示赋值
。 - 引用数据类型的静态常量,尤其是new String(“XXX”)这种形式,如:public static final String constStr1 = new String(“CONST”);都是在初始化中的中进行显示赋值的(即在方法
当中进行初始化的显示赋值,是在初始化阶段使用代码 的方式才会进行的显示赋值,然而在准备阶段不会有代码的执行) - 如果在static静态代码块中具有显示赋值操作(定义的后面没有赋值),那肯定就是在
初始化中的方法<clinit>
中显示赋值
3、环节3:链接阶段之Resolution(解析)
在准备阶段完成后,就进入了解析阶段。
解析阶段(Resolution),简言之:将类、接口、字段和方法的符号引用转为直接引用。
具体描述:
符号引用就是一些字面量的引用,和虚拟机的内部数据结构和和内存布局无关。比较容易理解的就是在Class类文件中,通过常量池进行了大量的符号引用。但是在程序实际运行时,只有符号引用是不够的,比如当如下println()方法被调用时,系统需要明确知道该方法的位置。
举例:输出操作System.out.println()对应的字节码:invokevirtual #24 <java/io/PrintStream. println>
以方法为例,Java虚拟机为每个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转变为目标方法在类中方法表中的位置,从而使得方法被成功调用。
小结:
所谓解析就是将符号引用转为直接引用,也就是得到类、字段、方法在内存中的指针或者偏移量。因此,可以说,如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段。但只存在符号引用,不能确定系统中一定存在该结构。
不过Java虚拟机规范并没有明确要求解析阶段一定要按照顺序执行。在HotSpotVM中,加载、验证、准备和初始化会按照顺序有条不紊地执行,但链接阶段中的解析操作往往会伴随着JVM在执行完初始化之后再执行。
字符串的复习:
最后,再来看一下CONSTANT_ String的解析。 由于字符串在程序开发中有着重要的作用,因此,读者有必要了解一下。
String在Java虚拟机中的处理。当在Java代码中直接使用字符串常量时,就会在类中出现CONSTANT_String,它表示字符串常量,并且会引用一个CONSTANT_UTF8的常量项。在Java虚拟机内部运行中的常量池中,会维护一张字符串拘留表(intern),它会保存所有出现过的字符串常量,并且没有重复项。只要以CONSTANT_String形式出现的字符串也都会在这张表中。使用String.intern()方法可以得到一个字符串在拘留表中的引用,因为该表中没有重复项,所以任何字面相同的字符串的String.intern()方法返回总是相等的。
4、过程三:Initialization(初始化)阶段
初始化阶段,简言之:为类的静态变量
赋于正确的初始值(显示赋值)。
具体描述:
类的初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,那么表示类可以顺利装载到系统中。此时,类才会开始执行Java字节码。(即: 到了初始化阶段,才真正开始执行类中定义的Java 程序代码。)
初始化阶段的重要工作是执行类的初始化方法:
- 该方法仅能由Java编译器生成并由JVM调用, 程序开发者无法自定义一个同名的方法,更无法直接在Java程序中调用该方法,虽然该方法也是由字节码指令所组成。
- 它是由**
类静态成员的赋值语句
以及static语句块
合并产生的**。
说明:
在加载一个类之前,虚拟机总是会试图加载该类的父类,因此父类的
总是在子类 之前被调用。也就是说,父类的static块优先级高于子类。口诀:由父及子,静态先行 Java编译器并不会为所有的类都产生
()初始化方法。哪些类在编译为字节码后,字节码文件中将不会包含 ()方法: 一个类中并没有声明任何的类变量,也没有静态代码块时
1
2//场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
public int num = 1;一个类中声明类变量,但是没有明确使用类变量的初始化语句以及静态代码块来执行初始化操作时
1
2//场景2:静态的字段,没有显式的赋值,不会生成<clinit>()方法
public static int num1;一个类中包含static final修饰的基本数据类型的字段,这些类字段初始化语句采用编译时常量表达式
1
2//场景3:比如对于声明为static final的基本数据类型的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
public static final int num2 = 1;
1、static与final的搭配问题(显示初始化问题)
问题研究:使用static + final
修饰的字段的显式赋值的操作,到底是在哪个阶段进行的赋值?
- 情况1:在链接阶段的准备环节赋值
- 情况2:在初始化阶段
()中赋值
实例代码:
1 | public class InitializationTest2 { |
初步结论:
- 在链接阶段的准备环节赋值的情况:
- 对于基本数据类型的字段来说,如果使用static final修饰,则显式赋值(直接赋值常量,而非调用方法进行动态赋值)通常是在链接阶段的准备环节进行
- 对于String来说,如果使用字面量的方式赋值,使用static final修饰的话,则显式赋值通常是在链接阶段的准备环节进行
- 在初始化阶段
()中赋值的情况: - 排除上述的在准备环节赋值的情况之外的情况。
实例代码:
1 | public class InitializationTest2 { |
最终结论:
- 在链接阶段的准备环节赋值的情况:
- 使用
static+final
修饰,并且进行显示赋值(定义的时候后面就已经附了确定的初始值),还不涉及到方法
或者构造器调用
的基本数据类型或者String类型字面量
(“XXX”这种形式,而不是new String(“XXX”)这种形式)的字段,将在准备中的链接阶段进行显示赋值 - 对于准备阶段就完成赋值的,**其字段下面的有属性
ConstantValue
**,在初始化阶段()中赋值的字段是没有属性ConstantValue的。
- 使用
- 在初始化阶段
()中赋值的情况: - 已经进行显示赋值的静态常量(
static+final
修饰)(包括引用类型,尤其是new String(“XXX”)这种类型的,还有调用其他方法获得的值,比如new Random().nextInt(10)等)或者静态变量(这是肯定在初始化方法中显示赋值)都将在初始化中的方法中进行显示赋值
- 已经进行显示赋值的静态常量(
- 使用
static + final
修饰,且显示赋值中不涉及到方法
或构造器调用的基本数据类型
或String类型的显式赋值
,是在链接阶段的准备环节进行。
补充:
- 换个角度思考下,只有在常量池中已经确定的值,才会在链接中的准备阶段赋值,像对象在常量池存储的一般都是符号引用,而并非是对象,仅仅是描述对象一个字符串,真正的对象还需通过字节码进行new,这一new不就得用类构造方法,不就得需要在初始化阶段
()中赋值了吗 - 计算中1/0,即
public static final int INT_CONSTANT = 1/0
,也不能在链接阶段的准备环节赋值,因为它会要抛异常,需要使用到代码 - 这里说的能够用常量池中数据表示是按照结果论,所以2/2这种的结果是一个int值可以表示
- 而new String(“”)是个特例,String的引用是可以在常量池中表示的,但是new String是在初始化阶段赋值的
- 也不能单纯看是不是字面量,如果是
static final Integer a = 1
,也是在初始化阶段()中赋值,只能说能尽量能在准备阶段赋值的就在准备阶段,实在不行才在初始化阶段 ()中赋值 - 另外一个角度:在链接阶段的准备环节赋值是不能动用代码的,因为真正开始执行类中定义的Java 程序代码是在到了初始化阶段才开始的。因此:可以在不使用java代码就能进行显示赋值的就在链接阶段的准备环节进行赋值,而赋值需要java代码参与的就只能在初始化阶段
()中进行显示赋值 。
2、()的线程安全性
对于
()方法的调用,也就是类的初始化,虚拟机会在内部确保其多线程环境中的安全性。 虚拟机会保证一个类的
()方法在多线程环境中被正确地加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 ()方法,其他线程都需要阻塞等待,直到活动线程执行 ()方法完毕。 正是因为函数
( )带锁线程安全的 ,因此,如果在一个类的()方法中有耗时很长的操作,就可能造成多个线程阻塞,引发死锁。并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息。 函数
( )带的锁是隐式的锁,并不是使用sychronized进行加锁的。
如果之前的线程成功加载了类,则等在队列中的线程就没有机会再执行
()方法了。那么,当需要使用这个类时虚拟机会直接返回给它已经准备好的信息。(一个类只需要加载一次)
死锁的相关代码:(使用两个进程让A、B交叉加载)
1 | class StaticA { |
由此得出结论:
- 编写代码的时候要尽量避免让类进行交叉加载或循环加载/依赖。
3、类的初始化情况:主动使用vs被动使用
Java程序对类的使用分为两种:
- 主动使用(调用了
()方法) - 被动使用(没有调用了
()方法)
注意:没有调用了
1、主动使用
Class只有在必须要首次使用的时候才会被装载,Java虚拟机不会无条件地装载Class类型。Java虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里指的“使用”,是指主动使用,主动使用只有下列几种情况:(即: 如果出现如下的情况,则会对类进行初始化操作。而初始化操作之前的加载、验证、准备已经完成。)
当创建一个类的实例时,比如使用new关键字,或者通过反射、克隆、反序列化。
举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52public class ActiveUse1 {
public static void main(String[] args) {
// 使用new关键字
Order order = new Order();
}
//序列化的过程:
public void test1() {
ObjectOutputStream oos = null;
try {
oos = new ObjectOutputStream(new FileOutputStream("order.dat"));
oos.writeObject(new Order());
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (oos != null)
oos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
//反序列化的过程:(验证)
public void test2() {
ObjectInputStream ois = null;
try {
ois = new ObjectInputStream(new FileInputStream("order.dat"));
Order order = (Order) ois.readObject();
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} finally {
try {
if (ois != null)
ois.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
class Order implements Serializable{
static {
System.out.println("Order类的初始化过程");
}
}
当调用类的静态方法时,即当使用了字节码
invokestatic
指令。举例:
1
2
3
4
5
6
7
8
9
10
11
12
public void test3(){
Order.method();
}
class Order implements Serializable{
static {
System.out.println("Order类的初始化过程");
}
public static void method(){
System.out.println("Order method()....");
}
}
当使用类、接口的静态字段时(final修饰特殊考虑),比如,使用getstatic或者putstatic指令。( 对应访问变量武值变量操作)
举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36public class ActiveUse2 {
public void test1(){
// System.out.println(User.num); // User类的初始化过程
// System.out.println(User.num1); // 1
System.out.println(User.num2); // User类的初始化过程 8
}
public void test2(){
// System.out.println(CompareA.NUM1); // 1
System.out.println(CompareA.NUM2); // CompareA的初始化 5
}
}
class User{
static{
System.out.println("User类的初始化过程");
}
public static int num = 1; // 在初始化阶段<clinit>()中赋值
public static final int num1 = 1; // 在链接阶段的准备环节赋值,不需要调用<clinit>()
public static final int num2 = new Random().nextInt(10); // 在初始化阶段<clinit>()中赋值
}
interface CompareA{
// 通过一个静态内部方法展示有没有JVM调用<clinit>()方法
public static final Thread t = new Thread(){
{
System.out.println("CompareA的初始化");
}
};
public static final int NUM1 = 1; // 在链接阶段的准备环节赋值,不需要调用<clinit>()
public static final int NUM2 = new Random().nextInt(10); // 在初始化阶段<clinit>()中赋值
}
当使用java.lang.reflect包中的方法反射类的方法时。比如:Class.forName(“com.atguigu.java.Test”);
举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
public void test1() {
try {
Class clazz = Class.forName("com.atguigu.java1.Order");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
class Order implements Serializable{
static {
System.out.println("Order类的初始化过程");
}
}
当初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void test2() {
// Father类的初始化过程
// Son类的初始化过程
// 1
System.out.println(Son.num);
}
class Father {
static {
System.out.println("Father类的初始化过程");
}
}
class Son extends Father implements CompareB{
static {
System.out.println("Son类的初始化过程");
}
public static int num = 1;
}其实在加载Father类之前,JVM还会加载Father的父类java.lang.Object。但是这里不好展示。可以通过JVM参数
-XX:+TraceClassLoading
可以追踪类的加载信息并打印出来。在当中可以看到JVM有先加载java.lang.Object。关于采用Junit进行测试时JVM参数的设置:
如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化。
举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public void test4() {
// Father类的初始化过程
// CompareB的初始化
// Son类的初始化过程
// 1
System.out.println(Son.num);
}
class Father {
static {
System.out.println("Father类的初始化过程");
}
}
class Son extends Father implements CompareB{
static {
System.out.println("Son类的初始化过程");
}
public static int num = 1;
}
interface CompareB {
public static final Thread t = new Thread() {
{
System.out.println("CompareB的初始化");
}
public default void method1(){
System.out.println("你好!");
}
};
}如果Son还被其他类继承的话,当Son的子类初始化的时候,对应的Son,CompareB,Father也会被初始化。
当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
举例:
1
2
3
4
5
6
7
8
9
10public class ActiveUse3 {
static{
System.out.println("ActiveUse3的初始化过程");
}
public static void main(String[] args) {
// ActiveUse3的初始化过程
// hello
System.out.println("hello");
}
}
当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。( 涉及解析REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄对应的类)
针对5,补充说明:
当Java虚拟机初始化一个类时,要求它的所有父类都已经被初始化,但是这条规则并不适用于接口。
在初始化一个类时,并不会先初始化它所实现的接口
举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public void test2() {
// Father类的初始化过程
// Son类的初始化过程
// 1
System.out.println(Son.num);
}
class Father {
static {
System.out.println("Father类的初始化过程");
}
}
class Son extends Father implements CompareB{
static {
System.out.println("Son类的初始化过程");
}
public static int num = 1;
}
interface CompareB {
public static final Thread t = new Thread() {
{
System.out.println("CompareB的初始化");
}
};
}虽然接口CompareB没有初始化,但是它已经被加载进JVM当中了。
在初始化一个接口时,并不会先初始化它的父接口
举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void test3(){
// CompareC的初始化
// 3
System.out.println(CompareC.NUM1);
}
interface CompareB {
public static final Thread t = new Thread() {
{
System.out.println("CompareB的初始化");
}
};
}
interface CompareC extends CompareB {
public static final Thread t = new Thread() {
{
System.out.println("CompareC的初始化");
}
};
public static final int NUM1 = new Random().nextInt();
}
因此,一个父接口并不会因为它的子接口或者实现类的初始化而初始化。只有当程序首次使用特定接口的静态字段时,才会导致该接口的初始化。
针对7,说明:
JVM启动的时候通过引导类加载器加载一个初始类。这个类在调用public static void main(String[])方法之前被链接和初始化。这个方法的执行将依次导致所需的类的加载,链接和初始化。
2、被动使用
除了以上的情况属于主动使用,其他的情况均属于被动使用。被动使用不会引起类的初始化。
也就是说:并不是在代码中出现的类,就一定会被加载或者初始化。如果不符合主动使用的条件,类就不会初始化。
当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。
当通过子类引用父类的静态变量,不会导致子类初始化
举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void test1(){
System.out.println(Child.num);
}
class Parent{
static{
System.out.println("Parent的初始化过程");
}
public static int num = 1;
}
class Child extends Parent{
static{
System.out.println("Child的初始化过程");
}
}
通过数组定义类引用,不会触发此类的初始化
举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void test2(){
// [空]
Parent[] parents = new Parent[10];
// class [Lcom.atguigu.java1.Parent;
System.out.println(parents.getClass());
// class java.lang.Object
System.out.println(parents.getClass().getSuperclass());
// Parent的初始化过程
parents[0] = new Parent();
parents[1] = new Parent();
}
class Parent{
static{
System.out.println("Parent的初始化过程");
}
}只是定义不赋值的话,不会触发类初始化。但是只要赋上一次值就会执行类的初始化,之后就不会执行类的初始化了。(
()这样一个类构造器方法只会初始化一次)
引用常量不会触发此类或接口的初始化。因为常量在链接阶段就已经被显式赋值了。
举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public void test1(){
// System.out.println(Person.NUM); // 1
// SerialA的初始化
// 3
System.out.println(Person.NUM1);
}
public void test2(){
// System.out.println(SerialA.ID); // 1
// Person类的初始化
// 3
System.out.println(SerialA.ID1);
}
class Person{
static{
System.out.println("Person类的初始化");
}
public static final int NUM = 1;//在链接过程的准备环节就被赋值为1了。
public static final int NUM1 = new Random().nextInt(10);//此时的赋值操作需要在<clinit>()中执行
}
interface SerialA{
public static final Thread t = new Thread() {
{
System.out.println("SerialA的初始化");
}
};
int ID = 1;
int ID1 = new Random().nextInt(10);//此时的赋值操作需要在<clinit>()中执行
}
调用ClassLoader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化。
举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void test3(){
try {
Class clazz = ClassLoader.getSystemClassLoader().loadClass("com.atguigu.java1.Person");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
class Person{
static{
System.out.println("Person类的初始化");
}
public static final int NUM = 1;//在链接过程的准备环节就被赋值为1了。
public static final int NUM1 = new Random().nextInt(10);//此时的赋值操作需要在<clinit>()中执行
}
3、注意
- ClassLoader.getSystemClassLoader().loadClass()方法与Class.forName()方法
- Class.forName()方法:类自动使用
- ClassLoader.getSystemClassLoader().loadClass()方法:类被动使用
5、过程四:类的Using(使用)
任何一个类型在使用之前都必须经历过完整的加载、链接和初始化3个类加载步骤。一旦一个类型成功经历过这3个步骤之后,就应经加载成功了。便“万事俱备,只欠东风”,就等着开发者使用了。
开发人员可以在程序中访问和调用它的静态类成员信息(比如:静态字段、静态方法),或者使用new关键字为其创建对象实例。
6、过程五:类的Unloading(卸载)
1、类、类的加载器、类的实例之间的引用关系
在类加载器的内部实现中,用一个Java集合来存放所加载类的引用。另一方面,一个Class对象总是会引用它的类加载器,调用Class对象的getClassLoader()方法, 就能获得它的类加载器。由此可见,代表某个类的Class实例与其类的加载器之间为双向关联关系。
一个类的实例总是引用代表这个类的Class对象。在Object类中定义了getClass()方法, 这个方法返回代表对象所属类的Class对象的引用。
此外,所有的Java类都有一个静态属性class,它引用代表这个类的Class对象。
2、类的生命周期
当Sample类被加载、链接和初始化后,它的生命周期就开始了。当代表Sample类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,Sample类在方法区内的数据也会被卸载,从而结束Sample类的生命周期。
一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期。
3、具体例子
loader1变量和obj变量间接引用代表Sample类的Class对象,而objClass变量则直接引用它。
- 关于方法区的垃圾回收(回顾):方法区的垃圾收集主要回收两部分内容:**
常量池中废弃的常量
和不再使用的类型
**。(可对应上面的图)- HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
- 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于 “不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收。也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收。这个条件除非是经过精心设计的可替换类加载器的场景,如
OSGi
、JSP
的重加载等,否则通常是很难达成的。 - 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
- Java虚拟机**被允许(不是必然)**对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。
如果程序运行过程中,将上图左侧三个引用变量都置为null,此时Sample对象结束生命周期,MyClassLoader对象结束生命周期,代表Sample类的Class对象也结束生命周期,Sample类在方法区内的二进制数据被卸载。
当再次有需要时,会检查Sample类的Class对象是否存在,如果存在会直接使用,不再重新加载;如果不存在Sample类会被重新加载,在Java 虚拟机的堆区会生成一个新的代表Sample类的Class实例(可以通过哈希码查看是否是同一个实例)。
4、类的卸载
启动类加载器加载的类型
在整个运行期间是不可能被卸载的(jvm和jls规范)- 被系统类加载器和扩展类加载器加载的类型在运行期间不太可能被卸载,因为系统类加载器实例或者扩展类的实例基本上在整个运行期间总能直接或者间接的访问的到,其达到unreachable的可能性极小。
- 被开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于强制调用虚拟机的垃圾收集功能才可以做到。可以预想,稍微复杂点的应用场景中(比如:很多时候用户在开发自定义类加载器实例的时候采用
缓存
的策略以提高系统性能),被加载的类型在运行期间也是几乎不太可能被卸载的(至少卸载的时间是不确定的)。
综合以上三点,一个已经加载的类型被卸载的几率很小至少被卸载的时间是不确定的。同时我们可以看的出来,开发者在开发代码时候,不应该对虚拟机的类型卸载做任何假设的前提下,来实现系统中的特定功能。
7、相关大厂面试题
- 蚂蚁金服:
- 描述一下JVM加载Class文件的原理机制?
- 一面:类加载过程
- 百度:
- 类加载的时机
- java类加载过程?
- 简述java类加载机制?
- 腾讯:
- JVM中类加载机制,类加载过程?
- 滴滴:
- JVM类加载机制
- 美团:
- Java类加载过程
- 描述一下jvm加载class文件的原理机制
- 京东:
- 什么是类的加载?
- 哪些情况会触发类的加载?
- 讲一下JVM加载一个类的过程
- JVM的类加载机制是什么?
4、再谈类的加载器
1、概述
类加载器是JVM执行类加载机制的前提。
ClassLoader的作用:
ClassLoader是Java的核心组件,所有的Class都是由ClassLoader进行加载的,ClassLoader 负责通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例。然后交给Java虚拟机进行链接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响到类的加载,而无法通过ClassLoader去改变类的链接和初始化行为。至于它是否可以运行,则由Execution Engine(执行引擎)
决定。
类加载器最早出现在Java1.0版本中,那个时候只是单纯地为了满足Java Applet应用而被研发出来。但如今类加载器却在OSGi、字节码加解密领域大放异彩。这主要归功于Java虚拟机的设计者们当初在设计类加载器的时候,并没有考虑将它绑定在JVM内部,这样做的好处就是能够更加灵活和动态地执行类加载操作。
1、类加载器的分类
类的加载分类:**显式加载
** vs 隐式加载
class文件的显式加载与隐式加载的方式是指JVM加载class文件到内存的方式。
- 显式加载:指的是在代码中通过调用ClassLoader加载class对象,如直接使用Class.forName(name)或this.getClass().getClassLoader().loadClass(name)加载class对象。
- 隐式加载:则是不直接在代码中调用ClassLoader的方法加载class对象,而是通过虚拟机自动加载到内存中,如在加载某个类的class文件时,该类的class 文件中引用了另外一个类的对象,此时额外引用的类将通过JVM自动加载到内存中(例如User user = new User())。(常用)
在日常开发以上两种方式一般会混合使用。
2、类加载器的必要性
一般情况下,Java开发人员并不需要在程序中显式地使用类加载器,但是了解类加载器的加载机制却显得至关重要。从以下几个方面说:
- 避免在开发中遇到
java.lang.ClassNotFoundException
异常或java.lang.NoClassDefFoundError
异常时手足无措。只有了解类加载器的加载机制才能够在出现异常的时候快速地根据错误异常日志定位问题和解决问题。 - 需要支持类的动态加载或需要对编译后的字节码文件进行加解密操作时,就需要与类加载器打交道了。
- 开发人员可以在程序中编写自定义类加载器来重新定义类的加载规则,以便实现一些自定义的处理逻辑。(自己定义的类加载器可以不遵从沙箱安全模型,因为沙箱安全模型有它的缺点)
3、命名空间
- 何为类的唯一性:
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使这两个类源自同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。 - 命名空间:
- 每个类加载器都有自己的命名空间,命名空间由该加载器及所有的父加载器所加载的类组成
- 在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类
- 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类
在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。
代码解释:
结果:
解释:
rootDir后面的地址是我们使用javac User.class指令生成的class文件地址,然后loader1和loader2是两个用户自定义类加载器(如果自定义的不必理解),之后使用这两个用户自定义类加载器加载同一类型的User类,获得的Class对象不是同一个,可以通过Class对象调用getClassLoader()方法获取对应的类加载器了,最后通过系统类加载器获取的Class对象也是独特的,也可以通过该Class对象获取系统类加载器
4、类加载机制的基本特征
通常类加载机制有三个基本特征:
- 双亲委派模型
- 但不是所有类加载都遵守这个模型,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如JDK内部的ServiceProvider/ServiceLoader机制,用户可以在标准API框架上,提供自己的实现,JDK也需要提供些默认的参考实现。
- 例如,Java 中JNDI、JDBC、文件系统、Cipher 等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。
- 安全 避免重复加载 保护程序 防止核心api被串改
- 可见性
- 子类加载器可以访问父加载器加载的类型,但是反过来是不允许的。不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
- 单一性
- 由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见。
2、复习:类的加载器分类
JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader) 和自定义类加载器(User-Defined ClassLoader) 。
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。无论类加载器的类型如何划分,在程序中我们最常见的类加载器结构主要是如下情况:
- 除了顶层的启动类加载器外,其余的类加载器都应当有自己的“父类”加载器。
- 不同类加载器看似是继承(Inheritance)关系,实际上是包含关系。在下层加载器中,包含着上层加载器的引用。
父类加载器和子类加载器的关系:
正是由于子类加载器中包含着父类加载器的引用,所以可以通过子类加载器的方法获取对应的父类加载器
注意:
启动类加载器通过C/C++语言编写,而自定义类加载器都是由Java语言编写的,虽然扩展类加载器和应用程序类加载器是被jdk开发人员使用java语言来编写的,但是也是由java语言编写的,所以也被称为自定义类加载器。
1、启动类加载器(引导类加载器,Bootstrap ClassLoader)
- 这个类加载使用C/C++语言实现的,嵌套在JVM内部。
- 它用来加载Java的核心库(JAVA_HOME/jre/lib/
rt.jar
或sun.boot.class.path
路径下的内容)。用于提供JVM自身需要的类。 - 并不继承自java.lang.ClassLoader,没有父加载器。
- 出于安全考虑,Bootstrap启动类加载器只加载包名为
java
、javax
、sun
等开头的类 - 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。(聚合)
使用-XX: +TraceClassloading
参数得到。
启动类加载器使用C++编写的? Yes!
- C/C++E 指针函数&函数指针、C++支持多继承、更加高效
- Java: 由C++演变而来, (C++)–版,单继承
引导类加载器需要加载的jar包文件:
代码:
结果:
2、扩展类加载器(Extension ClassLoader)
- Java语言编写,由
sun.misc.Launcher$ExtClassLoader
实现。 - 继承于ClassLoader类
- 父类加载器为启动类加载器。(聚合)
- 从
java.ext.dirs
系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录
下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
注意:扩展类加载器与系统类加载器是属于同一级的,都是继承与URLClassLoader
,只是扩展类加载器当中有系统类加载器的引用,所以才称系统类加载器是扩展类加载器的”父类”加载器。(两者并不是继承关系,而是一种聚合关系)
无法通过扩展类加载器获得引导类加载器,因为引导类加载器是用C/C++语言编写的,所以获取的值是null
扩展类加载器:
代码:
结果:
3、应用程序类加载器(系统类加载器,AppClassLoader)
- java语言编写,由
sun.misc.Launcher$AppClassLoader
实现 - 继承于ClassLoader类
- 父类加载器为扩展类加载器。(聚合)
- 它负责加载
环境变量classpath
或系统属性java.class.path指定路径
下的类库 - 应用程序中的类加载器默认是系统类加载器。
- 它是用户自定义类加载器的默认父加载器
- 通过ClassLoader的getSystemClassLoader( )方法可以获取到该类加载器
4、用户自定义类加载器
- 在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的。在必要时,我们还可以自定义类加载器,来定制类的加载方式。
- 体现Java语言强大生命力和巨大魅力的关键因素之一便是:Java开发者可以自定义类加载器来实现
类库的动态加载
,加载源可以是本地的JAR包,也可以是网络上的远程资源。 - 通过类加载器可以实现非常绝妙的插件机制,这方面的实际应用案例举不胜举。例如,著名的OSGI组件框架,再如Eclipse的插件机制。类加载器为应用程序提供了一种动态增加新功能的机制,这种机制无须重新打包发布应用程序就能实现。
- 同时,自定义加载器能够实现应用隔离,例如Tomcat,Spring等中间件和组件框架都在内部实现了自定义的加载器,并通过自定义加载器隔离不同的组件模块。这种机制比C/C++程序要好太多,想不修改C/C++程序就能为其新增功能,几乎是不可能的,仅仅一个兼容性便能阻挡住所有美好的设想。
- 自定义类加载器通常需要继承于ClassLoader.
3、测试不同的类的加载器
每个Class对象都会包含一个定义它的ClassLoader的一个引用。
获取ClassLoader的途径
1 | // 获得当前类的ClassLoader |
说明:
站在程序的角度看,引导类加载器与另外两种类加载器(系统类加载器和扩展类加载器)并不是同一个层次意义上的加载器,引导类加载器是使用C++语言编写而成的,而另外两种类加载器则是使用Java语言编写而成的。由于引导类加载器压根儿就不是一个Java类,因此在Java程序中只能打印出空值。
数组类的Class对象,不是由类加载器去创建的,而是在Java运行期JVM根据需要自动创建的。对于数组类的类加载器来说,是通过Class.getClassLoader( )返回的,与数组当中元素类型的类加载器是一样的;如果数组当中的元素类型是基本数据类型,数组类是没有类加载器的。
代码:
1 | //关于数组类型的加载:使用的类的加载器与数组元素的类的加载器相同 |
**获取当前线程上下文的ClassLoader的结果就是系统类加载器
**,这个可以在Launcher.java中被代码证明:
1 | this.loader = Launcher.AppClassLoader.getAppClassLoader(var1); |
代码:
1 | public class ClassLoaderTest1 { |
结果:
4、ClassLoader源码解析
ClassLoader与现有类加载器的关系:
除了以上虚拟机自带的加载器外,用户还可以定制自己的类加载器。Java提供了抽象类java.lang.ClassLoader, 所有用户自定义的类加载器都应该继承ClassLoader类。
1、类加载器之间的关系
Launcher.class:
ExtClassLoader和AppClassLoader是Launcher类的两个内部类:
分析:
- 验证扩展类加载器的父类是null
- 先看:var1 = Launcher.ExtClassLoader.
getExtClassLoader
(); - 获取到扩展类加载器,点击该方法往里面追溯,在最后找到:return new Launcher.
ExtClassLoader
(var0); - 我们在点击该方法往里面追溯,在找到:
super
(getExtURLs(var1), (ClassLoader)null, Launcher.factory); - 然后点击super,往里面追溯,在找到:public URLClassLoader(URL[] urls, ClassLoader parent,URLStreamHandlerFactory factory){
super
(parent)} - 点击其中的parent(也就是null值),我们点击super,往里面追溯,在找到:protected SecureClassLoader(ClassLoader parent) {
super
(parent);} - 点击其中的parent就是null,我们点击super,往里面追溯,在找到:protected ClassLoader(ClassLoader parent) {
this
(checkCreateClassLoader(), parent);} - 点击其中的parent就是null,我们点击this,往里面追溯,在找到:private ClassLoader(Void unused, ClassLoader parent) { this.
parent
= parent;} - 点击其中的parent就是null,可以看到是:private final ClassLoader
parent
;- 就是ClassLoader.java定义的属性:父类加载器
- 由于parent就是null,所以扩展类加载器的父类是null,也就是引导类加载器,因此我们调用获取扩展类加载器父类的方法获得的结果是null
- 先看:var1 = Launcher.ExtClassLoader.
- 验证系统类加载器的父类是扩展类加载器
- 先看:this.loader = Launcher.AppClassLoader.
getAppClassLoader
(var1); - 获取到系统类加载器,点击该方法往里面追溯,在最后找到:return new Launcher.
AppClassLoader
(var1x, var0); - 其中var0就是扩展类加载器,点击AppClassLoader,往里面追溯,在找到:AppClassLoader(URL[] var1, ClassLoader var2) {
super
(var1, var2, Launcher.factory); this.ucp.initLookupCache(this);} - 其中var2就是扩展类加载器,我们点击super,往里面追溯,在找到:public URLClassLoader(URL[] urls, ClassLoader parent,URLStreamHandlerFactory factory) {
super
(parent);} - 里面的parent就是扩展类加载器,我们点击super,往里面追溯,在找到:protected SecureClassLoader(ClassLoader parent) {
super
(parent);} - 里面的parent就是扩展类加载器,我们点击super,往里面追溯,在找到:protected ClassLoader(ClassLoader parent) {
this
(checkCreateClassLoader(), parent);} - 里面的parent就是扩展类加载器,我们点击this,往里面追溯,在找到:private ClassLoader(Void unused, ClassLoader parent) { this.
parent
= parent;} - 点击其中的parent就是扩展类加载器,可以看到是:private final ClassLoader
parent
;- 就是ClassLoader.java定义的属性:父类加载器
- 由于parent就是扩展类加载器,所以系统类加载器的父类是扩展类加载器,因此我们调用获取系统类加载器父类的方法获得的结果是扩展类加载器
- 先看:this.loader = Launcher.AppClassLoader.
- 当前线程上下文的ClassLoader就是系统类加载器
- Thread.currentThread().setContextClassLoader(this.loader):就是将系统类加载器设置为当前线程的上下文加载器,所以Thread.currentThread().getContextClassLoader()获取到的就是系统类加载器
注意:
- Launcher源码里定义了static的扩展类加载器ExtClassLoader, static的系统类加载器AppClassLoader。
- 它们都是默认包级别的,它们都是继承URLClassLoader,这就意味着我们的代码里,不能定义ExtClassLoader laoder = …或AppClassLoader loader = …。我们只能ClassLoader loader = …,而在实际运行时,我们应当能辨别这个loader到底是哪个具体类型。
- 在ExtClassLoader构造器里,并没有指定parent,或者说ExtClassLoader的parent为null。因为ExtClassLoader的parent是BootstrapLoader,而BootstrapLoader不存在于Java Api里,只存在于JVM里,我们是看不到的,所以请正确理解”ExtClassLoader的parent为null”的含义。
- 在AppClassLoader构造器里,有了parent。实例化AppClassLoader的时候,传入的parent就是一个ExtClassLoader实例。
- 看看Launcher的构造方法:
- **先实例化ExtClassLoader,从java.ext.dirs系统变量里获得URL[]**。
- **用这个ExtClassLoader作为parent去实例化AppClassLoader,从java.class.path系统变量里获得URL[]**。Launcher getClassLoader()就是返回的这个AppClassLoader。
- 设置AppClassLoader为ContextClassLoader。
2、ClassLoader的主要方法
抽象类ClassLoader的主要方法:(内部没有抽象方法)
public final ClassLoader getParent():
- 返回该类加载器的超类加载器
public Class<?> loadClass(String name) throws ClassNotFoundException:
- 加载名称为name的类,返回结果为java.lang.Class类的实例。如果找不到类,则返回ClassNotFoundException异常。该方法中的逻辑就是双亲委派模式的实现。
protected Class<?> findClass(String name) throws ClassNotFoundException:
- 查找二进制名称为name的类,返回结果为java.lang.Class类的实例。这是一个受保护的方法, JVM鼓励我们重写此方法,需要自定义加载器遵循双亲委托机制,该方法会在检查完父类加载器之后被loadClass()方法调用。
- 在JDK1.2之前,在自定义类加载时,总会去继承ClassLoader类并重写loadClass方法,从而实现自定义的类加载类。
- 但是在JDK1. 2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中,从前面的分析可知,findClass()方法是在loadClass()方法中被调用的,当loadClass()方法中父加载器加载失败后,则会调用自的findClass()方法来完成类加载,这样就可以保证自定义的类加载器也符合双亲委托模式。
- 需要注意的是ClassLoader类中并没有实现findClass()方法的具体代码逻辑,取而代之的是抛ClassNotFoundException异常,同时应该知道的是findClass 方法通常是和defineClass方法起使用的。一般情况下,在自定义类加载器时,会直接覆盖ClassLoader 的findClass()方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用defineClass()方法生成类的Class对象。
- 查找二进制名称为name的类,返回结果为java.lang.Class类的实例。这是一个受保护的方法, JVM鼓励我们重写此方法,需要自定义加载器遵循双亲委托机制,该方法会在检查完父类加载器之后被loadClass()方法调用。
protected final Class<?> defineClass(String name, byte[] b, int off, int len):
根据给定的字节数组b转换为Class的实例,off和len参数表示实际Class信息在byte数组中的位置和长度,其中byte数组b是ClassLoader从外部获取的。这是受保护的方法,只有在自定义ClassLoader子类中可以使用。
defineClass()方法是用来将byte字节流解析成JVM能够识别的Class对象(ClassLoader中已实现该方法逻辑),通过这个方法不仅能够通过class文件实例化class对象, 也可以通过其他方式实例化class对象, 如通过网络接收一个类的字节码,然后转换为byte 字节流创建对应的Class对象。
defineClass()方法通常与findClass()方法起使用,一般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则, 取得要加载类的字节码后转换成流, 然后调用defineClass()方法生成类的Class对象。
简单举例:
1
2
3
4
5
6
7
8
9
10protected Class<?> findClass(String name) throws ClassNotFoundException {
//获取类的字节数组
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
//使用defineClass生成class对象
return defineClass(name, classData, 0, classData. length);
}
}
protected final void resolveClass(Class<?> c):
- 链接指定的一个Java类。使用该方法可以使用类的Class对象创建完成的同时也被解析。前面我们说链接阶段主要是对字节码进行验证,为类变量分配内存并设置初始值同时将字节码文件中的符号引用转换为直接引用。
protected final Class<?> findLoadedClass(String name):
- 查找名称为name的已经被加载过的类,返回结果为java.lang.Class类的实例。这个方法是final方法,无法被修改。
private final ClassLoader parent;
- 它也是一个ClassLoader的实例,这个字段所表示的ClassLoader也称为这个ClassLoader的双亲。在类加载的过程中, ClassLoader可能会将某些请求交予自己的双亲处理。
关于loadClass()方法的剖析:
loadClass()方法是ClassLoader.java类中的主要方法。
测试代码:ClassLoader.getSystemClassLoader().loadClass(“com.atguig.java.User”);
涉及到对如下方法的调用:(模板方法模式的实现,抽象类提供基本的方法框架,子类需要重写具体的方法)
1 | protected Class<?> loadClass(String name, boolean resolve) //resolve: true-加载class的同时进行解析操作。默认为false |
分析:
findLoadedClass(name)
,查找类是否已经被加载过,如果加载过直接返回该Class类型的对象。如果没有被加载则继续第三的操作!c = findBootstrapClassOrNull(name);
和c = parent.loadClass(name, false);
如果父加载器不为空,那么调用父加载器的loadClass
方法加载类,如果父加载器为空,那么调用虚拟机的加载器来加载类。(此过程当中是通过递归的方法改变c的类型为父类加载器的类型,体现了双亲委派机制)如果以上两个步骤都没有成功的加载到类,进入第三;
c = findClass(name);
使用自定义的findClass(name)
方法来加载类。(递归回最初的那一层)这个时候,我们已经得到了加载之后的类,那么就根据
resolve
的值决定是否调用resolveClass
方法。进入第五!resolveClass(c);
链接指定的类。这个方法给Classloader
用来链接一个类,如果这个类已经被链接过了,那么这个方法只做一个简单的返回。否则,这个类将被按照Java™
规范中的Execution
描述进行链接……其中使用到了设计模式的模板方法模式
- 模板方法模式用于定义构建某个对象的步骤与顺序,或者定义一个算法的骨架。
- 模板方法模式的使用的方式,给子类足够的自由度,提供一些方法供子类覆盖,去实现一些骨架中不是必须但却可以有自定义实现的步骤。模板方法模式是一种基础继承的代码复用技术。如ClassLoader中的findClass方法!
总结一下:在ClassLoader中定义的算法顺序是
- 首先看是否有已经加载好的类。
- 如果父类加载器不为空,则首先从父类类加载器加载。
- 如果父类加载器为空,则尝试从启动加载器加载。
- 如果两者都失败,才尝试从findClass方法加载。
3、SecureClassLoader与URLClassLoader
- SecureClassLoader:
- 接着SecureClassLoader扩展了ClassLoader, 新增了几个与使用相关的代码源(对代码源的位置及其证书的验证)和权限定义类验证(主要指对class源码的访问权限)的方法,一般我们不会直接跟这个类打交道,更多是与它的子类URLClassLoader有所关联。
- URLClassLoader:
- 前面说过,ClassLoader是一个抽象类, 很多方法是空的没有实现,比如findClass()、findResource()等(模板方法模式)。而URLClassLoader这个实现类为这些方法提供了具体的实现。并新增了URLClassPath类协助取得Class字节码流等功能,在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类, 这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
4、ExtClassLoader与AppClassLoader
了解完URLClassLoader后接着看看剩余的两个类加载器,即拓展类加载器ExtClassLoader和系统类加载器AppClassLoader, 这两个类都继承自URLClassLoader, 是sun.misc.Launcher的静态内部类。
sun.misc.Launcher主要被系统用于启动主应用程序,ExtClassLoader 和AppClassLoader都是由sun.misc.Launcher创建的,其类主要类结构如下:
我们发现ExtClassLoader并没有重写loadClass()方法,这足矣说明其遵循双亲委派模式,而AppClassLoader重载了loadClass()方法,但最终调用的还是父类loadClass()方法,因此依然遵守双亲委派模式。
5、Class.forName()与ClassLoader.loadClass()
Class.forName()
:是一个静态方法,最常用的是Class.forName(String className);根据传入的类的全限定名返回一个Class对象。该方法在将Class文件加载到内存的同时,会执行类的初始化。如:Class.forName(“com. atguigu. java.HelloWorld”);ClassLoader.loadClass()
:这是一个实例方法,需要一个ClassLoader 对象来调用该方法。该方法将Class 文件加载到内存时,并不会执行类的初始化,直到这个类第一次使用时才进行初始化(loadClass()方法当中的resolve: true-加载class的同时进行解析操作。默认为false)。该方法因为需要得到一个ClassLoader 对象,所以可以根据需要指定使用哪个类加载器。如:ClassLoader c1=…….;c1. loadClass(“com. atguigu. java . HelloWorld”);
5、双亲委派模型
1、定义与本质
类加载器用来把类加载到Java虚拟机中。从JDK1. 2版本开始,类的加载过程采用双亲委派机制,这种机制能更好地保证Java平台的安全。
定义:
如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载。
本质:
规定了类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载。
2、优势与劣势
1、双亲委派机制优势
- 避免类的重复加载,确保一个类的全局唯一性;
- Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要于ClassLoader再加载一次。
- 保护程序安全,防止核心API被随意篡改
2、代码支持
双亲委派机制在java.lang.ClassLoader.loadClass(String, boolean)接口中体现。该接口的逻辑如下:
- 先在当前加载器的缓存中查找有无目标类,如果有,直接返回。
- 判断当前加载器的父加载器是否为空,如果不为空,则调用parent.loadClass(name, false)接口进行加载。
- 反之,如果当前加载器的父类加载器为空,则调用findBootstrapClassOrNull(name)接口, 让引导类加载器进行加载。
- 如果通过以上3条路径都没能成功加载,则调用findClass(name)接口进行加载。 该接口最终会调用java.lang.ClassLoader接口的defineClass系列的native接口加载目标Java类。
双亲委派的模型就隐藏在这第2和第3步的递归调用当中
3、举例:
假设当前加载的是java.lang.Object这个类,很显然,该类属于JDK中核心得不能再核心的一个类,因此一定只能由引导类加载器进行加载。当JVM准备加载java.lang.Object时,JVM默认会使用系统类加载器去加载,按照上面4步加载的逻辑,在第1步从系统类的缓存中肯定查找不到该类,于是进入第2步。由于从系统类加载器的父加载器是扩展类加载器,于是扩展类加载器继续从第1步开始重复。由于扩展类加载器的缓存中也一定查找不到该类,因此进入第2步。扩展类的父加载器是null,因此系统调用findClass(String),最终通过引导类加载器进行加载。
4、思考:
如果在自定义的类加载器中重写java.lang.ClassLoader.loadClass(String)或java.lang.ClassLoader.loadClass(String, boolean)方法, 抹去其中的双亲委派机制,仅保留上面这4步中的第1步与第4步,那么是不是就能够加载核心类库了呢?
这也不行!因为JDK还为核心类库提供了一层保护机制。不管是自定义的类加载器,还是系统类加载器抑或扩展类加载器,最终都必须调用java.lang.ClassLoader .defineClass
(String, byte[], int, int, ProtectionDomain)方法,而该方法会执行preDefineClass()
接口,该接口中提供了对JDK核心类库的保护。
5、双亲委派模型的弊端:
检查类是否加载的委托过程是单向的
**,这个方式虽然从结构上说比较清晰,使各个ClassLoader的职责非常明确,但是同时会带来一个问题,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类**。
通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题,但是系统类访问应用类就会出现问题。比如在系统类中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。
由双亲委派模型的优势可以看出:Java类随着它的类加载器一起具备了一种带有优先级的层次关系——双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API。但世事往往没有绝对的完美,如果基础类又要调用回用户的代码,由于BootstrapClassloader是顶级类加载器,BootstrapClassloader无法委派AppClassLoader来加载类,也就是说BootstrapClassloader中加载的类中无法使用由AppClassLoader加载的类。
举例:(java.sql与驱动接口:com.mysql.jdbc.Driver)
接口:java.sql.Driver,定义在java.sql包中,包所在的位置是:jdk\jre\lib\rt.jar中,java.sql包中还提供了其它相应的类和接口比如管理驱动的类:DriverManager类,很明显java.sql包是由BootstrapClassloader加载器加载的;而接口的实现类com.mysql.jdbc.Driver是由第三方实现的类库,由AppClassLoader加载器进行加载的,我们的问题是DriverManager再获取链接的时候必然要加载到com.mysql.jdbc.Driver类,这就是由BootstrapClassloader加载的类使用了由AppClassLoader加载的类,很明显和双亲委托机制的原理相悖。
6、结论:
由于Java虛拟机规范并没有明确要求类加载器的加载机制一定要使用双亲委派模型,只是建议采用这种方式而已。比如在Tomcat
中,类加载器所采用的加载机制就和传统的双亲委派模型有一定区别,当缺省的类加载器接收到一个类的加载任务时,首先会由它自行加载,当它加载失败时,才会将类的加载任务委派给它的超类加载器去执行,这同时也是Servlet规范推荐的一种做法。
3、破坏双亲委派机制
双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。
在Java的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,直到Java模块化出现为止,双亲委派模型主要出现过3次较大规模“被破坏”的情况。
1、破坏双亲委派机制1——兼容JDK1.2之前的版本
双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK 1.2面世以前的 “远古” 时代。
由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK1.2之后的java.lang.ClassLoader中添加一个新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass()中编写代码。
上节我们已经分析过loadClass()方法,双亲委派的具体逻辑就实现在这里面,按照loadClass()方法的逻辑, 如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。
以上简单来说就是jdk1.2之前还没引入双亲委派机制,所以jdk1.2之前就是破坏双亲委派机制的情况。
2、破坏双亲委派机制2——线程上下文类加载器(解决双亲委派机制的弊端:上层类加载器加载的类不能使用由下层类加载器加载的类)
双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?
这并非是不可能出现的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器来完成加载(在JDK 1.3时加入到rt.jar的),肯定属于Java中很基础的类型了。但JNDI 存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface, SPI) 的代码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?(SPI:在Java平台中, 通常把核心类rt.jar中提供外部服务、可由应用层自行实现的接口称为SPI)
为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)**。这个类加载器可以通过java.lang. Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器**。
有了线程上下文类加载器,程序就可以做一些“舞弊”的事情了。JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、JDBC、JCE、JAXB和JBl等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在JDK 6时,JDK提供了java.util.ServiceLoader类,**以META-INF/services中的配置信息,辅以责任链模式
**,这才算是给SPI的加载提供了一种相对合理的解决方案。
默认上下文加载器就是应用类加载器,这样以上下文加载器为中介,使得启动类加载器中的代码也可以访问应用类加载器中的类。
简单来说就是线程上下文类加载器让启动类加载器和系统类加载器直接联系起来了,中间的扩展类加载器被省略了,所以这破坏了双亲委派机制,其中线程上下文类加载器就是系统类加载器,这个证明在之前的ClassLoader >>> 类加载器之间的关系中 有相关代码的解释。
3、破坏双亲委派机制3——用户对程序动态性的追求:代码热替换(Hot Swap)、模块热部署(Hot Deployment) 等
双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的。如:代码热替换(Hot Swap)、模块热部署(Hot Deployment) 等
IBM公司主导的JSR-291 (即OSGi R4.2)实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起 换掉以实现代码的热替换。在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构。
当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:
- 将以
java.*
开头的类,委派给父类加载器加载。 - 否则,将委派列表名单内的类,委派给父类加载器加载。
- 否则,将Import列表中的类, 委派给Export这个类的Bundle的类加载器加载。
- 否则,查找当前Bundle的ClassPath, 使用自己的类加载器加载。
- 否则,查找类是否在自己的Fragment Bundle中, 如果在,则委派给Fragment Bundle的类加载器加载。
- 否则,查找Dynamic Import列表的Bundle, 委派给对应Bundle的类加载器加载。
- 否则,类查找失败。
说明:只有开头两点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的
小结:
这里,我们使用了“被破坏”这个词来形容上述不符合双亲委派模型原则的行为,但这里“被破坏”并不一定是带有贬义的。只要有明确的目的和充分的理由,突破旧有原则无疑是一种创新。
正如:OSGi中的类加载器的设计不符合传统的双亲委派的类加载器架构,且业界对其为了实现热部署而带来的额外的高复杂度还存在不少争议,但对这方面有了解的技术人员基本还是能达成一个共识,认为OSGi中对类加载器的运用是值得学习的,完全弄懂了OSGi的实现,就算是掌握了类加载器的精粹。
4、(补充)破坏双亲委派机制4——JDK9引入了Java模块化系统(具体在下文JDK9的新特性详细说明)
JDK9引入了Java模块化系统(Java Platform Module System)来实现可配置的封装隔离机制,同时JVM对类加载的架构也做出了调整,也就是双亲委派模型的第四次破坏。
传统的双亲委派加机制:(图示)这里的敌人就是我们要加载的jar包
缺点:
通过上面的漫画不言而喻,当真正的敌人来了,靠这种低效的传达机制,怎么可能打一场胜仗呢?
- 启动类加载器负责加载
\jre\lib目录 - 扩展类加载器负责加载
\jre\lib\ext目录 - 应用类加载器负责加载ClassPath目录。
既然一切都是各司其职,为什么不能加载类的时候一步到位呢?
通过分析JDK9的类加载器源码,我发现最新的类加载器结构在一定程度上是缓解了这种情况的
JDK的模块化:
在JDK9之前,JVM的基础类以前都是在rt.jar这个包里,这个包也是JRE运行的基石。这不仅是违反了单一职责原则,同样程序在编译的时候会将很多无用的类也一并打包,造成臃肿。
在JDK9中,整个JDK都基于模块化进行构建,以前的rt.jar, tool.jar被拆分成数十个模块,编译的时候只编译实际用到的模块,同时各个类加载器各司其职,只加载自己负责的模块。
模块化加载源码:
1 | Class<?> c = findLoadedClass(cn); |
上面代码就是破坏双亲委派模型的“铁证”,而当我们继续跟进findLoadedModule
,会发现是根据路径名找到对应的模块,而维护这一数据结构的就是下面这个Map:
1 | Map<String, LoadedModule> packageToModule = new ConcurrentHashMap<>(1024); |
可以看到LoadedModule里面不仅有该模块的loader信息,还有用于描述依赖模块,对外暴露模块的信息的mref,LoadedModule也是模块化实现封装隔离机制的一块重要实现。
每一个module信息都有一个BuiltinClassloader
,这个类有三个子类,我们通过源码分析他们的父子关系:
在ClassLoaders类中可以发现,PlatformClassLoader的parent是BootClassLoader,而AppClassLoader的parent则是PlatformClassLoader。
1 | public class ClassLoaders { |
结论:
- 经过破坏后的双亲委派模型更加高效,减少了很多类加载器之间不必要的委派操作
- JDK9的模块化可以减少Java程序打包的体积,同时拥有更好的隔离线与封装性
- 每个module拥有专属的类加载器,程序在并发性上也会更加出色
4、热替换的实现
热替换是指在程序的运行过程中,不停止服务,只通过替换程序文件来修改程序的行为。热替换的关键需求在于服务不能中断,修改必须立即表现正在运行的系统之中。基本上大部分脚本语言都是天生支持热替换的,比如:PHP, 只要替换了PHP源文件,这种改动就会立即生效,而无需重启Web服务器。
但对Java来说,热替换并非天生就支持,如果一个类已经加载到系统中,通过修改类文件,并无法让系统再来加载并重定义这个类。因此,在Java中实现这一功能的一个可行的方法就是灵活运用ClassLoader。
注意:由不同ClassLoader加载的同名类属于不同的类型,不能相互转换和兼容。即两个不同的ClassLoader加载同一个类,在虚拟机内部,会认为这两个类是完全不同的。根据这个特点,可以用来模拟热替换的实现,基本思路如下图所示:
每次调用方法之前都要加载字节码文件,然后创建对象,我们可以把字节码文件变成最新的,那么创建的对象肯定是最新的,所以这就完成了热替换
6、沙箱安全机制
沙箱安全机制:
- 保证程序安全
- 保护Java原生的JDK代码
**Java安全模型的核心就是Java沙箱(sandbox)**。什么是沙箱?
- 沙箱是一个限制程序运行的环境。
沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问。通过这样的措施来保证对代码的有限隔离,防止对本地系统造成破坏。
沙箱主要限制系统资源访问,那系统资源包括什么?
- CPU
- 内存
- 文件系统
- 网络
不同级别的沙箱对这些资源访问的限制也可以不一样。
所有的Java程序运行都可以指定沙箱,可以定制安全策略。
1、JDK1.0时期
在Java中将执行程序分成本地代码和远程代码两种:
- 本地代码默认视为可信任的,对于授信的本地代码,可以访问一切本地资源。
- 远程代码则被看作是不受信的。 而对于非授信的远程代码在早期的Java实现中, 安全依赖于沙箱(Sandbox)机制。
如下图所示JDK1 .0安全模型:
2、JDK1.1时期
JDK1.0中如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现。因此在后续的Java1.1版本中,针对安全机制做了改进,增加了安全策略。允许用户指定代码对本地资源的访问权限。
如下图所示JDK1.1安全模型:
3、JDK1.2时期
在Java1.2版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定,由类加载器加载到虛拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。
如下图所示JDK1.2安全模型:
4、JDK1.6时期
当前最新的安全机制实现,则引入了**域(Domain)
**的概念。
虚拟机会把所有代码加载到不同的系统域和应用域:
- **
系统域部分专门负责与关键资源进行交互
**; - 各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。
- 虚拟机中不同的受保护域(ProtectedDomain),对应不一样的权限(Permission)**。存在于不同域中的类文件就具有了当前域的全部权限**。
如下图所示,最新的安全模型(jdk1.6):
7、自定义类的加载器
1、为什么要自定义类加载器?
- 隔离加载类
- 在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境。
- 比如:阿里内某容器框架通过自定义类加载器确保应用中依赖的jar包不会影响到中间件运行时使用的jar包。
- 再比如:Tomcat这类Web应用服务器,内部自定义了好几种类加载器,用于隔离同一个Web应用服务器上的不同应用程序。
- 修改类加载的方式
- 类的加载模型并非强制,除Bootstrap外, 其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载
- 扩展加载源
- 比如从数据库、网络、甚至是电视机机顶盒进行加载
- 防止源码泄漏
- Java代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码。
2、常见的场景:
- 实现类似进程内隔离,类加载器实际上用作不同的命名空间,以提供类似容器、模块化的效果。例如,两个模块依赖于某个类库的不同版本,如果分别被不同的容器加载,就可以互不干扰。这个方面的集大成者是
Java EE
和OSGI
、JPMS
等框架。 - 应用需要从不同的数据源获取类定义信息,例如网络数据源,而不是本地文件系统。或者是需要自己操纵字节码,动态修改或者生成类型。
3、注意
在一般情况下,使用不同的类加载器去加载不同的功能模块,会提高应用程序的安全性。但是,如果涉及Java类型转换则加载器反而容易产生不美好的事情。在做Java类型转换时,只有两个类型都是由同一个加载器所加载,才能进行类型转换,否则转换时会发生异常。(两个不同的类加载器加载同一个class文件,得到的两个类,虽然表面上看上去是一样的,但是却是不一样的两个类。当在进行类型转换的时候会抛异常)。
4、自定义类加载器的实现方式
用户通过定制自己的类加载器,这样可以重新定义类的加载规则,以便实现一些自定义的处理逻辑。
1、实现方式:
- Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器都应该继承ClassLoader类。
- 在自定义ClassLoader的子类时候,我们常见的会有两种做法:
- 方式一:重写loadClass()方法(JDK1.2以前)
- 方式二:重写findClass()方法 –>推荐(JDK1.2以后)
2、对比
这两种方法本质上差不多,毕竟loadClass()也会调用findClass(), 但是从逻辑上讲我们最好不要直接修改loadClass()的内部逻辑。建议的做法是只在findClass()里重写自定义类的加载方法,根据参数指定类的名字,返回对应的Class对象的引用。
- loadClass()这个方法是实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此我们最好是在双亲委派模型框架内进行小范围的改动,不破坏原有的稳定结构。同时,也避免了自己重写loadClass()方法的过程中必须写双亲委托的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择。
- 当编写好自定义类加载器后,便可以在程序中调用loadClass()方法来实现类加载操作。
3、说明
- 其父类加载器是系统类加载器
- JVM中的所有类加载都会使用java.lang.ClassLoader.loadClass(String)接口(自定义类加载器并重写java.lang.ClassLoader.loadClass(String)接口的除外),连JDK的核心类库也不能例外。
- 如果你不想重写findClass()当中流相关的代码,同时也没什么需要改动的地方。可以尝试不去继承ClassLoader抽象类,而去继承抽象类ClassLoader的子类URLClassLoader,里面帮我们重写了findClass()方法。
4、实现代码
自定义类加载器:
1 | public class MyClassLoader extends ClassLoader { |
测试自定义类加载器:
1 | public class MyClassLoaderTest { |
注意:
- 需要将要加载的字节码文件放在一个文件下,或者重新javac编译一下源文件
- 不然的话JVM依旧会使用系统加载器去加载你的class文件(因为你的class文件在系统加载器的加载的目录下)
8、Java9新特性
为了保证兼容性,JDK 9没有从根本上改变三层类加载器架构和双亲委派模型,但为了模块化系统的顺利运行,仍然发生了一些值得被注意的变动。
扩展机制被移除,扩展类加载器由于向后兼容性的原因被保留,不过被重命名为平台类加载器(platform class loader)。可以通过ClassLoader的新方法getPlatformClassLoader()来获取。(JDK9之前只能获取到系统类加载器,再通过系统类获取扩展类加载器,现在是直接可以获取到平台类加载器)
- JDK 9时基于模块化进行构建(原来的rt.jar和tools.jar被拆分成数十个JMOD文件),其中的Java类库就已天然地满足了可扩展的需求,那自然无须再保留
\lib\ext目录,此前使用这个目录或者java.ext.dirs 系统变量来扩展JDK功能的机制已经没有继续存在的价值了。
- JDK 9时基于模块化进行构建(原来的rt.jar和tools.jar被拆分成数十个JMOD文件),其中的Java类库就已天然地满足了可扩展的需求,那自然无须再保留
平台类加载器和应用程序类加载器都不再继承自java.net.URLClassLoader。
现在**启动类加载器、平台类加载器、应用程序类加载器全都继承于
jdk.internal.loader.BuiltinClassLoader
**。如果有程序直接依赖了这种继承关系,或者依赖了URLClassLoader 类的特定方法,那代码很可能会在JDK 9及更高版本的JDK中崩溃。
在Java 9中,类加载器有了名称。该名称在构造方法中指定,可以通过getName()方法来获取。
- 平台类加载器的名称是platform
- 应用类加载器的名称是app
类加载器的名称在调试与类加载器相关的问题时会非常有用。
启动类加载器现在是在jvm内部和java类库共同协作实现的类加载器(以前是C++实现),但为了与之前代码兼容在获取启动类加载器的场景中仍然会返回null,而不会得到BootClassLoader实例。
类加载的委派关系也发生了变动:
- 当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。
双亲委派模式示意图:
相关代码:(环境:JDK9)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17public class ClassLoaderTest {
public static void main(String[] args) {
// jdk.internal.loader.ClassLoaders$AppClassLoader@726f3b58
System.out.println(ClassLoaderTest.class.getClassLoader());
// jdk.internal.loader.ClassLoaders$PlatformClassLoader@e73f9ac
System.out.println(ClassLoaderTest.class.getClassLoader().getParent());
// null
System.out.println(ClassLoaderTest.class.getClassLoader().getParent().getParent());
//获取系统类加载器
System.out.println(ClassLoader.getSystemClassLoader());
//获取平台类加载器
System.out.println(ClassLoader.getPlatformClassLoader());
//获取类的加载器的名称 app
System.out.println(ClassLoaderTest.class.getClassLoader().getName());
}
}附加:在Java模块化系统明确规定了三个类加载器负责各自加载的模块:
启动类加载器负责加载的模块:
平台类加载器负责加载的模块:
应用程序类加载器负责加载的模块:
9、大厂面试题
- 蚂蚁金服:
- 深入分析ClassLoader,双亲委派机制
- 类加载器的双亲委派模型是什么?
- 一面:双亲委派机制及使用原因
- 百度:
- 都有哪些类加载器,这些类加载器都加载哪些文件?
- 手写一个类加载器Demo
- Class的forName(“java.lang.String”)和Class的getClassLoader()的loadClass(“java.lang.String”)有什么区别?
- 腾讯:
- 什么是双亲委派模型?
- 类加载器有哪些?
- 小米:
- 双亲委派模型介绍一下
- 滴滴:
- 简单说说你了解的类加载器
- 一面:讲一下双亲委派模型,以及其优点
- 字节跳动:
- 什么是类加载器,类加载器有哪些?
- 京东:
- 类加载器的双亲委派模型是什么?
- 双亲委派机制可以打破吗?为什么
下篇:性能监控与调优篇
1、概述篇
1、背景说明
- 生产环境中的问题
- 生产环境发生了内存溢出该如何处理
- 生产环境应该给服务器分配多少内存合适?
- 如何对垃圾回收器的性能进行调优?
- 生产环境CPU负载飙高该如何处理?
- 生产环境应该给应用分配多少线程合适?
- 不加log,如何确定请求是否执行了某一行代码?
- 不加log,如何实时查看某个方法的入参与返回值?
- 为什么要调优
- 防止出现OOM
- 解决OOM
- 减少Full GC出现的频率
- 不同阶段的考虑
- 上线前
- 项目运行阶段
- 线上出现OOM
2、调优概述
- 监控的依据
- 运行日志
- 异常堆栈
- GC日志
- 线程快照
- 堆转储快照
- 调优的大方向
- 合理地编写代码
- 充分并合理的使用硬件资源
- 合理地进行JVM调优
3、性能优化的步骤
1、第1步(发现问题):性能监控
一种以非强行或者入侵方式收集或查看应用运营性能数据的活动。
监控通常是指一种在生产、 质量评估或者开发环境下实施的带有预防或主动性的活动。
当应用相关干系人提出性能问题却没有提供足够多的线索时,首先我们需要进行性能监控,随后是性能分析。
主要的问题有:
- GC频繁
- cpu load过高
- OOM
- 内存泄露
- 死锁
- 程序响应时间较长
2、第2步(排查问题):性能分析
一种以侵入方式收集运行性能数据的活动,它会影响应用的吞吐量或响应性。
性能分析是针对性能问题的答复结果,关注的范围通常比性能监控更加集中。
性能分析很少在生产环境下进行,通常是在质量评估、系统测试或者开发环境下进行,是性能监控之后的步骤。
主要的手段:
- 打印GC日志,通过GCviewer或者http://gceasy.io来分析异常信息
- 灵活运用命令行工具、jstack、jmap、jinfo等
- dump出堆文件,使用内存分析工具分析文件
- 使用阿里Arthas、jconsole、JVisualVM来实时查看JVM状态
- jstack查看堆栈信息
3、第3步(解决问题):性能调优
一种为改善应用响应性或香吐量而更改参数、源代码、属性配置的活动,性能调优
是在性能监控、性能分析之后的活动。
4、性能评价/测试指标
停顿时间(或响应时间)
提交请求和返回该请求的响应之间使用的时间,一般比较关注平均响应时间常用操作的响应时间列表:
在垃圾回收环节中:暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间(STW)。
- XX: MaxGCPauseMillis
吞吐量
对单位时间内完成的工作量(请求)的量度
在GC中:运行用户代码的事件占总运行时间的比例(总运行时间:程序的运行时间 + 内存回收的时间)
吞吐量为1-1/(1+n),其中-XX::GCTimeRatio=n(这个参数只有在G1才能设置)
并发数
同一时刻,对服务器有实际交互的请求数
大概的标准:1000个人同时在线,估计并发数在5% - 15%之间,也就是同时并发量: 50 - 150之间。
内存占用
Java堆区所占的内存大小
- 主要的指标有:响应时间和吞吐量
- 对于一个web应用关注的是:响应时间、吞吐量和并发数
- 对于GC的时候在意的数据
响应时间、吞吐量和并发数相互间的关系(以高速公路通行状况为例):
- 吞吐量:每天通过高速公路收费站的车辆的数据(也可以理解为收费站收取的高速费)
- 并发数:高速公路上正在行驶的车辆的数目
- 响应时间:车速
一开始的时候,高速公路上的车辆较少,车速较快,高速公路收费站收取的高速费较少——并发数少,响应时间快,吞吐量低
接着高速公路的车辆越来越多,车速变慢,高速公路收费站收取的高速费提高——并发数变多,响应时间变慢,吞吐量升高
随着高速公路的车辆越来越多,车速越来越慢,高速公路收费站收取的高速费降低——并发数越来越多,响应时间越来越慢,吞吐量降低
当高速公路的车辆发生事故,车速为0,高速公路收费站收取的高速费为0——并发数到顶,响应时间为0,吞吐量为0
2、JVM监控及诊断工具——命令行篇
1、概述
性能诊断是软件工程师在日常工作中需要经常面对和解决的问题,在用户体验至上的今天,解决好应用的性能问题能带来非常大的收益。
Java作为最流行的编程语言之一,其应用性能诊断一直受到业界广泛关注。可能造成Java应用出现性能问题的因素非常多,例如线程控制、磁盘读写、数据库访问、网络I/O、垃圾收集等。想要定位这些问题,一款优秀的性能诊断工具必不可少。
- 体会1:使用数据说明问题,使用知识分析问题,使用工具处理问题。
- 体会2:无监控、不调优!
简单命令行工具:
在我们刚接触java学习的时候,大家肯定最先了解的两个命令就是javac、java,那么除此之外,还有没有其他的命令可以供我们使用呢?
我们进入到安装jdk的bin目录,发现还有一系列辅助工具。这些辅助工具用来获取目标JVM不同方面、不同层次的信息,帮助开发人员很好地解决Java应用程序的一些疑难杂症。这些辅助工具都是一个.exe的可执行文件的方式,若想要找到他的来源:jdk > jdk1.8.0_131 > lib > tool.jar包当中(都是一些.class文件)。相关源码:一般不需要我们去查看源码,除非你有特殊需求,需要自己书写修改源码。
mac系统:
windows系统:
2、jps:查看正在运行的Java进程
1、基本介绍
jps(Java Process Status):显示指定系统内所有的HotSpot虚拟机进程(查看虚拟机进程信息),可用于查询正在运行的虚拟机进程。
说明:
- 对于本地虛拟机进程来说,进程的本地虚拟机ID与操作系统的进程ID是一致的,是唯一的。
- jps只对于在java HotSpot虚拟机运行的进程。
2、基本语法
它的基本使用语法为:jps [options] [hostid]
我们还可以通过追加参数,来打印额外的信息。可以通过 jps -help
来查看对应的参数信息
options参数
-q
:仅仅显示LVMID (local virtual machine id), 即本地虚拟机唯一id。不显示主类的名称等-l
:输出应用程序主类的全类名或如果进程执行的是jar包,则输出jar完整路径-m
:输出虚拟机进程启动时传递给主类main()的参数-v
:列出虚拟机进程启动时的JVM参数。- 比如:- Xms20m - Xmx50m是启动程序指定的jvm参数。
说明:以上参数可以综合使用:
- -q指令单独使用(效果与其他三个相反),-lmv可以一起使用
- jps -l -m 等价于 jps -lm
- jps -l -m -v 等价于 jps -lmv
- 如果-q与其他三个指令综合使用的话:(以-l为例,其他两个类似)
- jps -q -l 等价于 jps -q(所以没什么必要,直接执行jps -q就行)
- jps -ql:指令错误
- 如何将信息输出到同级文件中:
- 语法:命令 > 文件名称
- 例如:jps -l > a.txt
补充:
如果某Java进程关闭了默认开启的UsePerfData参数(即使用参数
-XX: -UsePerfData
),那么jps命令(以及下面介绍的jstat)将无法探知该
Java进程。
hostid参数
- RMI注册表中注册的主机名。如果想要远程监控主机上的java 程序,需要安装jstatd。
- 对于具有更严格的安全实践的网络场所而言,可能使用一个自定义的策略文件来显示对特定的可信主机或网络的访问,尽管这种技术容易受到IP地址欺诈攻击。
- 如果安全问题无法使用一个定制的策略文件来处理,那么最安全的操作是不运行jstatd服务器,而是在本地使用jstat和jps工具。
3、相关测试
- 10292:后面为空,查看任务管理器得知10292为操作系统为IDEA分配的进程ID,说明jps中后面为空的进程ID代表的就是IDEA进程(说明IDEA也是由java编写,运行在JVM虚拟机当中)
- 6328 ScannerTest:自己写的测试程序
- 14604 Jps:Jps本身的进程ID(说明在使用Jps的时候,Jps本身会创建一个进程)
- 2732 Launcher:JVM虚拟机的进程ID
3、jstat:查看JVM统计信息
1、基本介绍
jstat(JVM Statistics Monitoring Tool):用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、
JIT编译等运行数据。
在没有GUI图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。常用于检测垃圾回收问题以及内存泄漏问题。
2、基本语法
它的基本使用语法为:jstat -
其中vmid是进程id号,也就是jps之后看到的前面的号码,如下:
查看命令相关参数:jstat -h
或jstat -help
option参数
类装载相关的:
-class
:显示ClassLoader的相关信息:类的装载、卸载数量、总空间、类装载所消耗的时间等9000为进程ID(
)
垃圾回收相关的:
-gc
:显示与GC相关的堆信息。包括Eden区、两个Survivor区、老年代、永久代等的容量、已用空间、GC时间合计等信息。其中设置了JVM参数:-Xms60m -Xmx60m -XX:SurvivorRatio=8
- C:总容量\次数、U:使用的容量、S1\0:Survivor0\1区、E:伊甸园区、O:老年代、M:方法区、CCS:压缩类、YG:young GC、FG:full GC、GC:GC、T:时间
- 由于设置了-Xms60m -Xmx60m -XX:SurvivorRatio=8,即60M = 40M(Old) + 20M(young) 20M(young) = 16M(伊甸园区) + 2M(S0) + 2M(S1)
- S0C:Survivor0区的容量:2M =2048
- S1C:Survivor1区的容量:2M = 2048
- S0U:Survivor0区使用的容量
- S1U:Survivor1区使用的容量
- EC:伊甸园区的容量:16M = 16384
- EU:伊甸园区使用的容量
- OC:老年代的容量:40M = 40960
- OU:老年代使用的容量
- MC:方法区的容量
- MU:方法区使用的容量
- CCSC:压缩类的容量
- CCSU:压缩类使用的容量
- YGC:young GC发生的次数
- YGCT:发生young GC花费的时间
- FGC:full GC发生的次数
- FGCT:发生full GC花费的时间
- GCT:用于GC的时间
-gccapacity
:显示内容与-gc
基本相同,但输出主要关注Java堆各个区域使用到的最大、最小空间。-gcutil
:显示内容与-gc
基本相同,但输出主要关注已使用空间占总空间的百分比。-gccause
:与-gcutil
功能一样,但是会额外输出导致最后一次或当前正在发生的GC产生的原因。-gcnew
:显示新生代GC状况-gcnewcapacity
:显示内容与-gcnew
基本相同,输出主要关注使用到的最大、最小空间-geold
:显示老年代GC状况-gcoldcapacity
:显示内容与-gcold
基本相同,输出主要关注使用到的最大、最小空间-gcpermcapacity
:显示永久代使用到的最大、最小空间。
JIT相关的:
-compiler
:显示JIT编译器编译过的方法、耗时等信息-printcompilation
:输出已经被JIT编译的方法
interval参数:用于指定输出统计数据的周期,单位为毫秒。即:查询间隔。如果没加的话,默认查询1次,如果后面的count没有加的话,默认一直查询。
count参数:用于指定查询的总次数。在加上interval参数的情况下,没加count参数,默认一直查询
每1s显示一次ClassLoader的相关信息,显示10次。
-t参数:可以在输出信息前加上一个Timestamp列,显示程序的运行时间。单位:秒
经验:
我们可以比较Java进程的启动时间以及总GC时间(GCT列),或者两次测量的间隔时间以及总GC时间的增量,来得出GC时间占运行时间的比例。
如果该比例超过20%,则说明目前堆的压力较大;如果该比例超过90%,则说明堆里几乎没有可用空间,随时都可能抛出OOM异常。
我们执行jstat -gc -t 13152 1000 10,这代表1秒打印出1行,一共10行,-t代表打印出Timestamp总运行时间,结果如下所示:
上方红色框框中代表Timestamp,而蓝色框框中代表垃圾回收时间,单位都是秒,如果让红色框框中的某两个值相减,假设这个值是num1,然后让对应行的蓝色框框中的另外两个值相减,假设这个值是num2,之后让num2/num1,得出的差值就是上述所说的GC时间占运行时间的比例
h参数:可以在周期性数据输出时,输出多少行数据后输出一个表头信息
3、补充
jstat还可以用来判断是否出现内存泄漏。
- 第1步:在长时间运行的Java程序中,我们可以运行jstat命令连续获取多行性能数据,并取这几行数据中OU列(即己占用的老年代内存)的最小值。
- 第2步:然后,我们每隔一段较长的时间重复一次上述操作,来获得多组OU最小值。如果这些值呈上涨趋势,则说明该Java程序的老年代内存已使用量在不断上涨,这意味着无法回收的对象在不断增加,因此很有可能存在内存泄漏。
4、jinfo:实时查看和修改JVM配置参数
1、基本介绍
jinfo(Configuration Info for Java):查看虚拟机配置参数信息,也可用于调整虚拟机的配置参数。
在很多情况下,Java应用程序不会指定所有的Java虚拟机参数。而此时,开发人员可能不知道某一个具体的Java虚拟机参数的默认值。在这种情况下,可能需要通过查找文档获取某个参数的默认值。这个查找过程可能是非常艰难的。但有了jinfo工具,开发人员可以很方便地找到Java虛拟机参数的当前值。
2、基本语法
它的基本使用语法为:jinfo [ options ] pid
说明:java进程ID必须要加上。
[options] :
选项 | 选项说明 |
---|---|
no option | 输出全部的参数和系统属性 |
-flag name | 输出对应名称的参数 |
-flag [+-]name | 开启或关闭对应名称的参数 只有被标记为manageable的参数才可以被动态修改 |
-flag name=value | 设定对应名称的参数 |
-flags | 输出全部的参数 |
-sysprops | 输出系统属性 |
查看
jinfo -sysprops 进程id:可以查看由System.getProperties()取得的参数
进程id可以通过jps命令查看,操作结果如下:(其中13152代表进程id)
jinfo -flags 进程id:查看曾经赋过值的一些参数
进程id可以通过jps命令查看,参数赋值的一部分是我们自己设置的,另外一部分是系统自动优化设置的参数信息,具体操作如下:(其中13152代表进程id)
jinfo -flag 参数名称 进程id:查看某个java进程的具体参数信息
进程id可以通过jps命令查看具体操作如下:(其中3540代表进程id)
修改
jinfo不仅可以查看运行时某一个Java虚拟机参数的实际取值,甚至可以在运行时修改部分参数,并使之立即生效。但是,并非所有参数都支持动态修改。参数只有被标记为
manageable
的flag可以被实时修改其实,这个修改能力是极其有限的。#可以查看被标记为manageable的参数:java -XX:+PrintFlagsFinal -version | grep manageable
针对boolean类型
jinfo -flag [+|-]参数名称 进程id
针对非boolean类型
jinfo -flag 参数名称=参数值 进程id
3、拓展
java -XX:+PrintFlagsInitial
查看所有JVM参数启动的初始值
java -XX:+PrintFlagsFinal
查看所有JVM参数的最终值
值前面添加冒号
:
的是修改之后的值,没有添加的都是没有发生改变的初始值java -参数名称:+PrintCommandLineFlags
查看那些已经被用户或者JVM设置过的详细的XX参数的名称和值
5、jmap:导出内存映像文件&内存使用情况
1、基本介绍
jmap(JVM Memory Map):作用方面是获取dump文件 (堆转储快照文件,二进制文件),它还可以获取目标Java进程的内存相关信息,包括Java堆各区域的使用情况、堆中对象的统计信息、类加载信息等。
开发人员可以在控制台中输入命令“jmap -help” 查阅jmap工具的具体使用方式和一些标准选项配置。
2、基本语法
它的基本使用语法为:
- jmap [option]
- jmap [option] <executable
- jmap [option] [server_ id@]
其中的option包括:
选项 | 作用 |
---|---|
-dump | 生成dump文件 |
-finalizerinfo | 显示在F-Queue中等待Finalizer线程执行finalize方法的对象 |
-heap | 输出整个堆空间的详细信息,包括GC的使用、堆配置信息,以及内存的使用信息等等 |
-histo | 输出堆空间中对象的统计信息,包括类、实例数量和合计容量 |
-permstat | 以ClassLoader为统计口径输出永久代的内存状态信息 |
-F | 当虚拟机对-dump选项没有任何响应的时候,强制执行生成dump文件 |
- 使用语法可以通过在DOS窗口中使用jmap/jmap -h/jmap -help查看jmap使用语法
- 文件名称代表可执行的代码,比如使用> 文件名称来指定生成的dump文件的生成位置
- [server_id@]是为远程连接准备的
指令描述:
- **
-dump
**:生成Java堆转储快照:dump文件- 特别的:
-dump:live
只保存堆中的存活对象
- 特别的:
- **
-heap
**:输出整个堆空间的详细信息,包括GC的使用、堆配置信息,以及内存的使用信息等 - **
-histo
**:输出堆中对象的同级信息,包括类、实例数量和合计容量- 特别的:
-histo:live
只统计堆中的存活对象
- 特别的:
-permstat
:以ClassLoader为统计口径输出永久代的内存状态信息- 仅linux/solaris平台有效
-finalizerinfo
:显示在F-Queue中等待Finalizer线程执行finalize方法的对象- 仅linux/solaris平台有效
-F
:当虚拟机进程对-dump选项没有任何响应时,可使用此选项强制执行生成dump文件- 仅linux/solaris平台有效
-h | -help
:jamp工具使用的帮助命令-J <flag>
:传递参数给jmap启动的jvm
3、基本使用
1、使用1:导出内存映像文件
一般来说,使用jmap指令生成dump文件的操作算得上是最常用的jmap命令之一,将堆中所有存活对象导出至一个文件之中。
Heap Dump又叫做堆存储文件,指一个Java进程在某个时间点的内存快照。Heap Dump在触发内存快照的时候会保存此刻的信息如下:
- All Objects
Class,fields,primitive values and references - All Classes
ClassLoader,name,super class,static fields - Garbage Collection Roots
Objects defined to be reachable by the JVM - Thread Stacks and Local Variables
The call-stacks of threads at the moment of the snapshot,and per-frame information about local objects
说明:
- 通常在写Heap Dump文件前会触发一次Full GC, 所以heap dump文件里保存的都是Full GC后留下的对象信息。
- 由于生成dump文件比较耗时,因此大家需要耐心等待,尤其是大内存镜像生成dump文件则需要耗费更长的时间来完成。
注意:
- 对于以上说明中的第1点是自动方式才会这样做,而手动不会在Full GC之后生成Dump
- 使用手动方式生成dump文件,一般指令执行之后就会生成,不用等到快出现OOM的时候
- 使用自动方式生成dump文件,当出现OOM之前先生成dump文件
- 如果使用手动方式,一般使用第2种,毕竟生成堆中存活对象的dump文件是比较小的,便于传输和分析
生成dump文件的方式:
手动的方式
- jmap -dump:format=b,file=<filename.hprof>
- jmap -dump:live,format=b,file=<filename.hprof>
- 小结的内容
说明:
- 其中file=后面的是生成的dump文件地址,最后的11696是进程id,可以通过jps查看
- filename中的filename是文件名称,而.hprof是后缀名,代表该值可以省略
- format=b表示生成的是标准的dump文件,用来进行格式限定
- 一般使用的是第二种方式,也就是生成堆中存活对象的快照,毕竟这种方式生成的dump文件更小,我们传输处理都更方便
具体例子如下:
- jmap -dump:format=b,file=<filename.hprof>
自动的方式
当程序发生OOM退出系统时,一些瞬时信息都随着程序的终止而消失,而重现OOM问题往往比较困难或者耗时。此时若能在OOM时,自动导出dump文件就显得非常迫切。这里介绍一种比较常用的取得堆快照文件的方法,即使用:
-XX: +HeapDumpOnOutOfMemoryError
:在程序发生OOM时,导出应用程序的当前堆快照。-XX:HeapDumpPath
:可以指定堆快照的保存位置。- 比如:
- -Xmx100m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D: \m. hprof
- 具体使用如下:
2、使用2:显示堆内存相关信息
jmap -heap 进程id
jmap -heap 进程id
只是时间点上的堆信息,而jstat
后面可以添加参数,可以指定时间动态观察数据改变情况,而图形化界面工具,例如jvisualvm
等,它们可以用图表的方式动态展示出相关信息,更加直观明了。使用例子:
jmap -histo 进程id
输出堆中对象的同级信息,包括类、实例数量和合计容量,也是这一时刻的内存中的对象信息
使用例子:
3、使用3:其他作用
- jmap -permstat 进程id
- 查看系统的ClassLoader信息(永久代)
- jmap -finalizerinfo
- 查看堆积在finalizer队列中的对象
这两个指令仅linux/solaris平台有效,所以无法在windows操作平台上演示,并且使用比较小众,不在多说
4、小结
由于jmap将访问堆中的所有对象,为了保证在此过程中不被应用线程干扰,jmap需要借助安全点机制,让所有线程停留在不改变堆中数据的状态。也就是说,由jmap导出的堆快照必定是安全点位置的。这可能导致基于该堆快照的分析结果存在偏差。
举个例子,假设在编译生成的机器码中,某些对象的生命周期在两个安全点之间,那么-dump:live
选项将无法探知到这些对象。
另外,如果某个线程长时间无法跑到安全点,jmap将一直等下去。
与前面讲的jstat则不同,垃圾回收器会主动将jstat所需要的摘要数据保存至固定位置之中,而jstat只需直接读取即可。
6、jhat:JDK自带堆分析工具
jhat命令在jdk9及其之后就被移除了,官方建议使用VisualVm代替jhat,所以该指令只需简单了解一下即可
1、基本介绍
jhat(JVM Heap Analysis Tool):Sun JDK提供的jhat命令与jmap命令搭配使用,**用于分析jmap生成的heap dump文件(堆转储快照)**。
jhat内置了一个微型的HTTP/HTML服务器(会CPU造成一定的压力),生成dump文件的分析结果后,用户可以在浏览器中查看分析结果(分析虚拟机转储快照信息)。
使用了jhat命令, 就启动了一个http服务,端口是7000, 即http://localhost:7000/,就可以在浏览器里分析。
说明:jhat 命令在JDK9、JDK10中已经被删除,官方建议用VisualVM
代替。
2、基本语法
它的基本使用语法为:jhat [option] [dumpfile]
其中dumpfile代表dump文件的地址以及名称,例如:jhat d:\1.hprof
option参数:
-stack false|true
:关闭|打开对象分配调用栈跟踪-refs false|true
:关闭|打开对象引用跟踪-port port-number
:设置jhat HTTP Server的端口号,默认7000。例子:jhat -port 6565-exclude exclude-file
:执行对象查询时需要排除的数据成员-debug int
:设置debug级别-version
:启动后显示版本信息就退出-J<flag>
:传入启动参数,比如-J -Xmx512m
例子:
注意:
- 使用jhat一次只能分析一个.hprof文件,如果要分析另外一个.hprof文件的话,需要将之前打开的jhat关闭。
7、jstack:打印JVM中线程快照
1、基本介绍
jstack(JVM Stack Trace):用于生成虚拟机指定进程当前时刻的线程快照(虚拟机堆栈跟踪)。线程快照就是当前虚拟机内指定进程的每一条线程正在执行的方法堆栈的集合。
生成线程快照的作用:可用于定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等问题。这些都是导致线程长时间停顿的常见原因。当线程出现停顿时,就可以用jstack显示各个线程调用的堆栈情况。
在thread dump中,要留意下面几种状态:
- 死锁:Deadlock (重点关注)
- 等待资源:Waiting on condition (重点关注)
- 等待获取监视器:Waiting on monitor entry (重点关注)
- 阻塞:Blocked (重点关注)
- 执行中:Runnable
- 暂停:Suspended
- 对象等待中:Object.wait() 或 TIMED_WAITING
- 停止:Parked
2、基本语法
它的基本使用语法为:jstack option pid
jstack管理远程进程的话,需要在远程程序的启动参数中增加:
- -Djava.rmi.server.hostname=……
- -Dcom.sun.management.jmxremote
- -Dcom.sun.management.jmxremote.port=8888
- -Dcom.sun.management.jmxremote.authenticate=false
- -Dcom.sun.management.jmxremote.ssl=false
总结:如果程序出现等待问题,可以使用该指令去查看问题所在,结果中也会提示你问题所在
option参数:
-F
:当正常输出的请求不被响应时,强制输出线程堆栈-l
:除堆栈外,显示关于锁的附加信息-m
:如果调用本地方法的话,可以显示C/C++的堆栈-h
:帮助操作
在java层面实现jstack功能:
1 | Map<Thread, StackTraceElement[]> all = Thread.getAllStackTraces(); //追踪当前进程中的所有的线程 |
例子:
死锁问题:
使用sleep:
同步问题:
8、jcmd:多功能命令行
1、基本介绍
在JDK 1.7以后,新增了一个命令行工具jcmd。
它是一个多功能的工具,可以用来实现前面除了jstat之外的所有命令的功能。比如:用它来导出堆、内存使用、查看java进程、导出线程信息、执行GC、JVM运行时间等等。
jcmd拥有jmap的大部分功能,并且在Oracle的官方网站上也推荐使用jcmd命令代替jmap命令。
2、基本语法
jcmd -l:列出所有的JVM进程
jcmd 进程号 help:针对指定的进程,列出支持的所有具体命令
执行效果:
jcmd 进程号 具体命令:显示指定进程的指令命令的数据
- 首先通过jcmd 进程号 help得出以下命令列表
- 根据以上命令来替换之前的那些操作:
- Thread.print 可以替换 jstack指令
- GC.class_histogram 可以替换 jmap中的-histo操作
- GC.heap_dump 可以替换 jmap中的-dump操作
- GC.run 可以查看GC的执行情况
- VM.uptime 可以查看程序的总执行时间,可以替换jstat指令中的-t操作
- VM.system_properties 可以替换 jinfo -sysprops 进程id
- VM.flags 可以获取JVM的配置参数信息
9、jstatd:远程主机信息收集
之前的指令只涉及到监控本机的Java应用程序,而在这些工具中,一些监控工具也支持对远程计算机的监控(如jps、jstat)。为了启用远程监控,则需要配合使用jstatd 工具。
命令jstatd是一个RMI服务端程序,它的作用相当于代理服务器,建立本地计算机与远程监控工具的通信。 jstatd服务器将本机的Java应用程序信息传递到远程计算机。
3、JVM监控及诊断工具——GUI篇
1、工具概述
使用上一章命令行工具或组合能帮您获取目标Java应用性能相关的基础信息,但它们存在下列局限:
- 无法获取方法级别的分析数据,如方法间的调用关系、各方法的调用次数和调用时间等(这对定位应用性能瓶颈至关重要)
- 要求用户登录到目标Java应用所在的宿主机上,使用起来不是很方便。
- 分析数据通过终端输出,结果展示不够直观。
为此,JDK提供 了一些内存泄漏的分析工具,如jconsole、jvisualvm等, 用于辅助开发人员定位问题,但是这些工具很多时候并不足以满足快速定位的需求。所以这里我们介绍的工具相对多一些、丰富一些。
图形化综合诊断工具:
- JDK自带的工具
jconsole
:JDK自带的可视化监控工具。查看Java应用程序的运行概况、监控堆信息、永久区(或元空间)使用情况、类加载情况等- 位置:jdk\bin\jconsole.exe
Visual VM
:Visual VM是一个工具,它提供了一个可视界面,用于查看Java虚拟机上运行的基于Java技术的应用程序的详细信息。- 位置: jdk\bin\jvisualvm.exe
JMC
:Java Mission Control,内置Java Flight Recorder
**。能够以极低的性能开销收集Java虚拟机的性能数据**。
- 第三方工具
MAT
:MAT(Memory Analyzer Tool) 是基于Eclipse的内存分析工具,是一个快速、功能丰富的Java heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗- Eclipse的插件形式
JProfiler
:商业软件,需要付费。功能强大。- 与VisualVM类似
Arthas
:Alibaba开源的Java诊断工具。深受开发者喜爱。Btrace
:Java运行时追踪工具。可以在不停机的情况下,跟踪指定的方法调用、构造函数调用和系统内存等信息。
2、JConsole
1、基本概述
jconsole:
- 从Java5开始,在JDK中自带的java监控和管理控制台。
- 用于对JVM中内存、线程和类等的监控,是一个基于JMX(java management extensions )的GUI性能监控工具。
2、启动
- 在jdk安装目录中找到jconsole.exe,双击该可执行文件就可以
- 打开DOS窗口,直接输入jconsole就可以了
3、三种连接方式
Local
使用JConsole连接一个正在本地系统运行的JVM,并且执行程序的和运行JConsole的需要是同一个用户。
JConsole使用文件系统的授权通过RMI连接起链接到平台的MBean的服务器上。这种从本地连接的监控能力只有Sun的JDK具有。
注意:本地连接要求 启动jconsole的用户 和 运行当前程序的用户 是同一个用户
具体操作如下:
在DOS窗口中输入jconsole
在控制台上填写相关信息
选择“不安全的连接”
进入控制台页面
Remote
- 使用下面的URL通过RMI连接器连接到一个JMX代理,service:jmx:rmi:///jndi/rmi://hostName:portNum/jmxrmi。
- JConsole为建立连接,需要在环境变量中设置mx.remote.credentials来指定用户名和密码,从而进行授权。
Advanced
- 使用一个特殊的URL连接JMX代理。
- 一般情况使用自己定制的连接器而不是RMI提供的连接器来连接JMX代理,或者是一个使用JDK1.4的实现了JMX和JMX Rmote的应用
4、主要作用
概览
内存
根据线程检测死锁
线程
VM 概要
3、Visual VM
1、基本概述
VisualVM是一个功能强大的多合一故障诊断和性能监控的可视化工具。
它集成了多个JDK命令行工具,使用VisualVM可用于显示虚拟机进程及进程的配置和环境信息(jps、jinfo),监视应用程序的CPU、GC、堆、方法区及线程的信息(jstat、jstack)等, 也可以代替JConsole。
在JDK 6 Update 7以后,Visual VM便作为JDK的一部分发布(VisualVM在JDK/bin目录下)
- 即:它完全免费。
此外,Visual VM也可以作为独立的软件安装:
使用:
- 在jdk安装目录中找到jvisualvm.exe,然后双击执行即可
- 打开DOS窗口,输入jvisualvm就可以打开该软件
2、插件的安装
Visual VM的一大特点是支持插件扩展,并且插件安装非常方便。我们既可以通过离线下载插件文件*.nbm,然后在Plugin对话框的已下载页面下,添加已下载的插件。也可以在可用插件页面下,在线安装插件。(这里建议安装上:VisualGC)
IDEA安装VisualVM Launcher插件:Preferences –> Plugins –> 搜索VisualVM Launcher,安装重启即可。
在IDEA中安装插件:首先在IDEA中搜索VisualVM Launcher插件并安装
重启IDEA,然后配置该插件:
使用两种方式来运行程序:
运行效果:还是打开jvisualvm界面,只是不需要我们手动打开jvisualvm而已
3、连接方式
- 本地连接
- 监控本地Java进程的CPU、类、线程等
- 远程连接
- 确定远程服务器的ip地址
- 添加JMX(通过JMX技术具体监控远程服务器哪个Java进程)
- 修改bin/catalina.sh文件,连接远程的tomcat
- 在…/conf中添加jmxremote.access和jmxremote.password文件
- 将服务器地址改成公网ip地址
- 设置阿里云安全策略和防火墙策略
- 启动tomcat,查看tomcat启动日志和端口监听
- JMX中输入端口号、用户名、密码登录
4、主要功能
生成/读取/对比堆内存快照
生成堆内存快照:
方式1:
方式2:
注意:
生成堆内存快照如下图:
这些快照存储在内存中,当线程停止的时候快照就会丢失,如果还想利用,可以将快照进行另存为操作,如下图:
装入堆内存快照
dump文件对比
查看JVM参数和系统属性
查看运行中的虚拟机进程
生成/读取线程快照
生成线程快照
方式1:
方式2:
注意:
生成线程快照如下图:
这些快照存储在内存中,当线程停止的时候快照就会丢失,如果还想利用,可以将快照进行另存为操作,如下图:
装入线程快照
程序资源的实时监控
抽样器
CPU
内存
其他功能
- JMX代理连接
- 远程环境监控
- CPU分析和内存分析
4、Eclipse MAT
1、基本概述
MAT(Memory Analyzer Tool)工具是一款功能强大的Java堆内存分析器。可以用于查找内存泄漏以及查看内存消耗情况
MAT是基于Eclipse开发的, 不仅可以单独使用,还可以作为插件的形式嵌入在Eclipse中使用。是一款免费的性能分析工具,使用起来非常方便。大家可以在
下载并使用MAT。
只要确保机器上装有JDK并配置好相关的环境变量,MAT可正常启动。还可以在Eclipse中以插件的方式安装:
注意:如果单独使用,那么解压即可用,不需要安装即可
2、获取堆dump文件
1、dump文件内存
MAT可以分析heap dump文件。 在进行内存分析时,只要获得了反映当前设备内存映像的hprof文件,通过MAT打开就可以直观地看到当前的内存信息。
一般说来,这些内存信息包含:
- 所有的对象信息,包括对象实例、成员变量、存储于栈中的基本类型值和存储于堆中的其他对象的引用值。
- 所有的类信息,包括classloader、 类名称、父类、静态变量等GCRoot到所有的这些对象的引用路径
- 线程信息,包括线程的调用栈及此线程的线程局部变量(TLS)
2、两点说明
说明1:缺点:
MAT 不是一个万能工具,它并不能处理所有类型的堆存储文件。但是比较主流的厂家和格式,例如 Sun,HP,SAP所采用的HPROF二进制堆存储文件,以及IBM的PHD堆存储文件等都能被很好的解析。说明2:
最吸引人的还是能够快速为开发人员生成内存泄漏报表,方便定位问题和分析问题。虽然MAT有如此强大的功能,但是内存分析也没有简单到一键完成的程度,很多内存问题还是需要我们从MAT展现给我们的信息当中通过经验和直觉来判断才能发现。
3、获取dump文件
方法一:通过前一章介绍的jmap工具生成,可以生成任意一个java进程的dump文件;
方法二:通过配置JVM参数生成。
- 选项”
-XX:+HeapDumpOnOutOfMemoryError
“ 或”-XX:+HeapDumpBeforeFullGC
“ - 选项”
-XX:HeapDumpPath
“所代表的含义就是当程序出现0utofMemory时, 将会在相应的目录下生成一份dump文件。如果不指定选项“-XX:HeapDumpPath
“ 则在当前目录下生成dump文件。 - 对比:考虑到生产环境中几乎不可能在线对其进行分析,大都是采用离线分析,因此使用jmap+MAT工具是最常见的组合。
- 选项”
方法三:使用VisualVM可以导出堆dump文件
方法四:使用MAT既可以打开一个已有的堆快照,也可以通过MAT直接从活动Java程序中导出堆快照。该功能将借助jps列出当前正在运行的Java进程,以供选择并获取快照。
4、加载dump文件
相关解释:
- Leak Suspects Report(堆泄露疑点报告):
- 自动检查堆转储是否存在泄漏嫌疑。 报告哪些对象保持活动状态以及为什么它们没有被垃圾回收器回收。
- Component Report(组件报告):
- 分析一组对象是否存在疑似内存问题:重复字符串、空集合、终结器、弱引用等
- Re-open previously run reports(重新打开以前运行的报告):
- 现有报告存储在堆dump同一目录下的 ZIP 文件中
4、分析堆dump文件
相关图例:
通过分析堆dump文件可以得到:
是否存在类的重复加载:
相关的报告:
Heap Dump Overview(堆dump的概述):
Leak Suspects(堆泄露疑点):
Top Components(顶级组件):通过图形列举出最大对象的情况
histogram:展示了各个类的实例数目以及这些实例的Shallow heap或者Retained heap的总和
使用:
图标:
具体内容:
查找一个类:
Group by package (根据包进行分组):(默认是Group by class)
排序:
正则表达式:(精准搜索)
若一个对象可能存在内存泄露(内存泄露疑点),怎么查看?
将两份内存映像文件的直方图进行对比:(以下图片的”树状图”修改为”直方图”)
thread overview
查看系统中的Java线程
查看局部变量的信息
使用:
图标:
具体内容:
获得对象互相引用的关系
with outgoing references(出引用)
图示:
结果:
with incoming references(入引用)
图示:
结果:
分析:
- 若发现此时该对象只有一些生命周期较短的线程(方法/方法里的引用变量)去引用它,则该对象就是可以被GC进行回收,不会存在内存泄露问题
- 若发现此时该对象还有另外一些生命周期较长的线程(方法/方法里的引用变量)去引用它,则该对象就不能被GC回收,就存在了内存泄露问题。
- 解决方法:可以将该引用从强引用修改为软引用或弱引用。
浅堆与深堆(与浅拷贝与深拷贝一一对应)
shallow heap
浅堆(Shallow Heap)是指一个对象所消耗的内存。在32位系统中,一个对象引用会占据4个字节,一个int类型会占据4个字节,long型变量会占据8个字节,每个对象头需要占用8个字节。根据堆快照格式不同,对象的大小可能会向8字节进行对齐。
以String为例:2个int值共占8字节,对象引用占用4字节,对象头8字节,合计20字节,向8字节对齐,故占24字节。(jdk7中)
这24字节为String对象的浅堆大小。它与String的value实际取值无关,无论字符串长度如何,浅堆大小始终是24字节。
对象头代表根据类创建的对象的对象头,还有对象的大小不是可能向8字节对齐,而是就向8字节对齐(一定)。
注意一下:这里对象头除去类型指针的大小为8字节,然后类型指针看是否启用了引用压缩,如果启用了,对象头总共就是12字节,否则就是16字节。(32位机是不支持指针压缩的)
retained heap
- 保留集(Retained Set):
- 对象A的保留集指当对象A被垃圾回收后,可以被释放的所有的对象集合(包括对象A本身),即对象A的保留集可以被认为是只能通过对象A被直接或间接访问到的所有对象的集合。通俗地说,就是指仅被对象A所持有的对象的集合。
- 深堆(Retained Heap):
- 深堆是指对象的保留集中所有的对象的浅堆大小之和。
- 注意:
- 浅堆指对象本身占用的内存,不包括其内部引用对象的大小。一个对象的深堆指只能通过该对象访问到的(直接或间接)所有对象的浅堆之和,即对象被回收后,可以释放的真实空间。
- 当前深堆大小 = 当前对象的浅堆大小 + 对象中所包含对象的深堆大小
- 保留集(Retained Set):
补充:对象实际大小
另外一个常用的概念是对象的实际大小。这里,对象的实际大小定义为一个对象所能触及的(这里要与深堆的”只有通过”**相区分)所有对象的浅堆大小之和,也就是通常意义上我们说的对象大小。与深堆相比,似乎这个在日常开发中更为直观和被人接受,但实际上,这个概念和垃圾回收无关**。
下图显示了一个简单的对象引用关系图,对象A引用了C和D,对象B引用了C和E。那么对象A的
浅堆
大小只是A本身,不含C和D,而A的实际大小
为A、C、D三者之和。而A的深堆
大小为A与D之和,由于对象C还可以通过对象B访问到,因此不在对象A的深堆范围内。
练习
看图理解Retained Size
解答:
- 上图中,GC Roots直接引用了A和B两个对象。
- A对象的Retained Size = A对象的Shallow Size
- B对象的Retained Size = B对象的Shallow Size + C对象的Shallow Size
- 这里不包括D对象,因为D对象被GC Roots直接引用。
如果GC Roots不引用D对象呢?
- 那么B对象的Retained Size = B对象的Shallow Size + C对象的Shallow Size + D对象的Shallow Size
- 因为此时的D对象只有通过B对象进行引用
案例分析:StudentTrace
代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90/**
* 有一个学生浏览网页的记录程序,它将记录 每个学生访问过的网站地址。
* 它由三个部分组成:Student、WebPage和StudentTrace三个类
*
* -XX:+HeapDumpBeforeFullGC -XX:HeapDumpPath=d:\student.hprof
* @author shkstart
* @create 16:11
*/
public class StudentTrace {
static List<WebPage> webpages = new ArrayList<WebPage>();
public static void createWebPages() {
for (int i = 0; i < 100; i++) {
WebPage wp = new WebPage();
wp.setUrl("http://www." + Integer.toString(i) + ".com");
wp.setContent(Integer.toString(i));
webpages.add(wp);
}
}
public static void main(String[] args) {
createWebPages();//创建了100个网页
//创建3个学生对象
Student st3 = new Student(3, "Tom");
Student st5 = new Student(5, "Jerry");
Student st7 = new Student(7, "Lily");
for (int i = 0; i < webpages.size(); i++) {
if (i % st3.getId() == 0)
st3.visit(webpages.get(i));
if (i % st5.getId() == 0)
st5.visit(webpages.get(i));
if (i % st7.getId() == 0)
st7.visit(webpages.get(i));
}
webpages.clear();
System.gc();
}
}
class Student {
private int id;
private String name;
private List<WebPage> history = new ArrayList<>();
public Student(int id, String name) {
super();
this.id = id;
this.name = name;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<WebPage> getHistory() {
return history;
}
public void setHistory(List<WebPage> history) {
this.history = history;
}
public void visit(WebPage wp) {
if (wp != null) {
history.add(wp);
}
}
}
class WebPage {
private String url;
private String content;
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}图示:
结论:
- 这里三个学生对象的浅堆大小都是24字节:4(id) + 4(name) + 4(history) + 8(对象头) = 20 –> 24(向8字节对齐)
- 其实这里如果是32位虚拟机,需要补对齐填充。
- 但如果不是是32位虚拟机,而且存在类型指针压缩的话:对象头应该有12字节 –> 4 + 4 + 4 + 12 = 24
- 普通Java对象头的大小为12字节或16字节,默认采用了指针压缩则为12字节,没有采用则为16字节(数组还需要加上数组长度)
- 同理每一个网页的浅堆大小也都是24字节4(url) + 4(content) + 12(对象头) = 20 –> 24(向8字节对齐)
- 这里三个学生对象的浅堆大小都是24字节:4(id) + 4(name) + 4(history) + 8(对象头) = 20 –> 24(向8字节对齐)
解释:(为什么elementData数组的深堆为1288个字节)
- 普通Java对象头的大小为12字节或16字节。默认采用了指针压缩则为12字节,没有采用则为16字节(数组还需要加上数组长度)。详情参考博客。
- 为什么有152字节和144字节:
- 因为我们的URL和content存在两种情况(个位数与十位数)
- URL:http://www.7.com content:7
- URL:http://www.14.com content:14
- 第一种URL长度为16,底层的char数组的占用空间为(【】方括号里面整个都属于对象头,分开写方便大家理解)
- 【普通对象头(12) + 数组长度(4)】 + 16个字符(32) = 48字节,符合8字节对齐
- 同理content 占用 【普通对象头(12) +数组长度(4)】+ 一个字符(2) = 18字节,八字节对齐 = 24字节
- 第二种URL长度为17,底层的插入数组的占用空间为
- 【普通对象头(12) + 数组长度(4)】 + 17个字符(34) = 50字节,不符合8字节对齐,对齐为56
- 同理content 占用 【普通对象头(12) +数组长度(4)】+ 两个字符(4) = 20字节,八字节对齐 = 24字节
- 所以第一种总字节为48 + 24 = 72,第二种总字节为56 + 24 = 80。因此第二种比第一种多了8字节,所以是152和144
- 为什么总大小是152而不是72?
- 因为我们只计算了String底层的char数组的区别没有计算各变量本身的浅堆,因为结构都相同,所以差别就差在内容的占用上
- 因为我们的URL和content存在两种情况(个位数与十位数)
- 为什么最终结果是1288?
- 首先ElementData数组本身的浅堆大小为:【普通对象头(12) + 数组长度(4)】 + 数组内容【15个Obejct引用 = 15 * 4】 = 76,八字节对齐 = 80字节
- 15个Object分为13个152字节 + 2个144字节,总大小为 = 2264字节
- 7号和其他student重复的有0、21、42、63、84、35、70总计6个152和1一个144
- 所以2264 - 6 * 152 - 144 = 1208字节
- 所以ElementData本身的浅堆80 + 仅能通过它到达的浅堆1208 = 1288
- 为什么ArrayList的长度是15?(并不是因为ArrayList的内容是15个,对于Jerry同学来说:ArrayList的长度是22,但是ArrayList的内容只有21)
- 这是和ArrayList的扩容有关
- ArrayList默认的长度为10,当长度超过10的时候,ArrayList就会自动扩容,扩容系数是0.5
- 即ArrayList的长度 = 10 * 1.5 = 15
- 当超过扩容后的长度(15),ArrayList会再次扩容:15 * 1.5 = 22
支配树
支配树(Dominator Tree )(支配树的概念源自图论(统计学))
MAT提供了一个称为支配树(Dominator Tree) 的对象图。支配树体现了对象实例间的支配关系。在对象引用图中,所有指向对象B的路径都经过对象A,则认为对象A支配对象B。如果对象A是离对象B最近的一个支配对象,则认为对象A为对象B的直接支配者。支配树是基于对象间的引用图所建立的,它有
以下基本性质:- 对象A的子树(所有被对象A支配的对象集合)表示对象A的保留集(retained set),即深堆。
- 如果对象A支配对象B,那么对象A的直接支配者也支配对象B。
- 支配树的边与对象引用图的边不直接对应。
如下图所示:左图表示对象引用图,右图表示左图所对应的支配树。对象A和B由根对象直接支配,由于在到对象C的路径中,可以经过A,也可以经过B,因此对象C的直接支配者也是根对象。对象F与对象D相互引用,因为到对象F的所有路径必然经过对象D,因此,对象D是对象F的直接支配者。而到对象D的所有路径中,必然经过对象C,即使是从对象F到对象D的引用,从根节点出发,也是经过对象C的,所以,对象D的直接支配者为对象C。同理,对象E支配对象G。到达对象H的可以通过对象D,也可以通过对象E,因此对象D和E都不能支配对
象H,而经过对象C既可以到达D也可以到达E,因此对象C为对象H的直接支配者。注意:
- 跟随我一起来理解如何从“对象引用图—》支配树”,首先需要理解支配者(如果要到达对象B,毕竟经过对象A,那么对象A就是对象B的支配者,可以想到支配者大于等于1),
- 然后需要理解直接支配者(在支配者中距离对象B最近的对象A就是对象B的直接支配者,你要明白直接支配者不一定就是对象B的上一级,然后直接支配者只有一个),
- 然后还需要理解支配树是怎么画的,其实支配树中的对象与对象之间的关系就是直接支配关系,也就是上一级是下一级的直接支配者,只要按照这样的方式来作图,肯定能从“对象引用图 —》支配树”
在Eclipse MAT工具中如何查看支配树:
在MAT中,单击工具栏上的对象支配树按钮,可以打开对象支配树视图。
下图显示了对象支配树视图的一部分。该截图显示部分Lily学生的history队列的直接支配对象。即当Lily对象被回收,也会一并回收的所有对象。显然能被3或者5整除的网页不会出现在该列表中,因为它们同时被另外两名学生对象引用。(15(总) - 7(被其他引用) = 8(可回收))
4、案例:Tomcat堆溢出分析
说明:
- Tomcat是最常用的Java Servlet容器之一 , 同时也可以当做单独的Web服务器使用。Tomcat本身使用Java实现,并运行于Java虚拟机之上。在大规模请求时,Tomcat有可能会因为无法承受压力而发生内存溢出错误。这里根据一个被压垮的Tomcat的堆快照文件, 来分析Tomcat在崩溃时的内部情况。
分析过程:
查看大对象(主要分析的对象):
查看当前最大的对象它到底引用了哪些具体的内部结构:
查看该大对象中哪一部分占用了大部分内存:
继续往里查看:
继续往里查看:
找到出现问题的对象后,可以通过OOL语句查询出想要的对象
查看该对象的创建时间与结束时间,判断他是不是一个生命周期短的对象:
根据找到的信息进行分析:
5、支持使用OQL语言查询对象信息
- SELECT子句
- FROM子句
- WHERE子句
- 内置对象与方法
5、JProfiler
1、基本概述
1、介绍
在运行Java的时候有时候想测试运行时占用内存情况,这时候就需要使用测试工具查看了。在eclipse里面有Eclipse Memory Analyzer tool (MAT)插件可以测试,而在IDEA中也有这么一个插件,就是JProfiler。
JProfiler是由ej-technologies公司开发的一款Java应用性能诊断工具。功能强大,但是收费。
2、特点
- 使用方便、界面操作友好( 简单且强大)
- 对被分析的应用影响小(提供模板)
- CPU, Thread , Memory分析功能尤其强大
- 支持对jdbc、noSql、jsp、servlet、socket等进行分析
- 支持多种模式(离线,在线)的分析
- 支持监控本地、远程的JVM
- 跨平台,拥有多种操作系统的安装版本
3、主要功能
- 方法调用
- 对方法调用的分析可以帮助您了解应用程序正在做什么,并找到提高其性能的方法
- 内存分配
- 通过分析堆上对象、引用链和垃圾收集能帮您修复内存泄露问题,优化内存使用
- 线程和锁
- JProfiler提供多种针对线程和锁的分析视图助您发现多线程问题
- 高级子系统
- 许多性能问题都发生在更高的语义级别上。例如,对于JDBC调用,您可能希望找出执行最慢的SQL语句。JProfiler支持对这些子系统进行集成分析
2、安装与配置
1、下载与安装
2、JProfiler中配置IDEA
IDE Integrations
选择合适的IDE版本
开始集成
正式集成
集成成功
点击OK即可
3、IDEA集成JProfiler
安装JProfiler插件
方式1:在线安装
方式2、离线安装
首先下载插件:
准备离线安装:
正式离线安装:
注意:无论采用方式1还是方式2都需要重启IDEA
将JProfiler配置到IDEA中
3、具体使用
启动:
- 相关说明:
- Profile a demo session or a saved session(配置demo会话或保存一个会话):
- JProfiler附带了几个预先配置的演示会话。你可以让他们开始探索JProfiler的特征。
- Attach to a running JVM(连接到正在运行的JVM):
- JProfiler可以连接到本地或远程运行的jvm,并动态地分析它们。一些附加模式下不支持功能。
- Profile an application server, locally or remotely(本地或远程配置应用程序服务器):
- JProfiler提供了对所有主要应用服务器的广泛支持。两个应用服务器支持在此计算机和远程计算机上运行。
- Open a snapshot(打开快照):
- JProfiler可以保存快照以及以后可以打开的所有分析结果。而且,它可以打开HPROF和PHD快照。
- Profile a demo session or a saved session(配置demo会话或保存一个会话):
- 相关说明:
数据采集方式
- JProfier数据采集方式分为两种:
Sampling
(样本采集)和Instrumentation
(重构模式)Instrumentation
:这是JProfiler全功能模式。在class加载之前,JProfier把相关功能代码写入到需要分析的class的bytecode中,对正在运行的jvm有一定影响。- 优点:功能强大。在此设置中,调用堆栈信息是准确的。
- 缺点:若要分析的class较多,则对应用的性能影响较大,CPU开销可能很高(取决于Filter的控制)。因此使用此模式一般配合Filter使用,只对特定的类或包进行分析
Sampling
:类似于样本统计,每隔一定时间(5ms )将每个线程栈中方法栈中的信息统计出来。- 优点:对CPU的开销非常低,对应用影响小(即使你不配置任何Filter)
- 缺点:一些数据/特性不能提供(例如:方法的调用次数、执行时间)
- 注:JProfiler本身没有指出数据的采集类型,这里的采集类型是针对方法调用的采集类型。因为JProfiler的绝大多数核心功能都依赖方法调用采集的数据,所以可以直接认为是JProfiler的数据采集类型。
- 推荐使用Sampling方式,足够用来分析OOM问题了
- JProfier数据采集方式分为两种:
遥感监测 Telemetries
- 其中Telemetries就是遥感监测的意思
内存视图 Live Memory
Live memory(内存剖析):class/class instance的相关信息。例如对象的个数, 大小,对象创建的方法执行栈,对象创建的热点。
所有对象All Objects:显示所有加载的类的列表和在堆上分配的实例数。只有Java 1.5 (JVMTI)才会显示此视图。(浅堆)
记录对象Record Objects:查看特定时间段对象的分配,并记录分配的调用堆栈。
注意:默认关闭,若开启的话,会导致系统的性能急剧的降低。
开启的时机:判断内存泄露的时候开启
使用:
分配访问树Allocation Call Tree:显示一棵请求树或者方法、 类、包或对己选择类有带注释的分配信息的J2EE组件。
分配热点Allocation Hot Spots:显示一个列表,包括方法、类、包或分配已选类的J2EE组件。你可以标注当前值并且显示差异值。对
于每个热点都可以显示它的跟踪记录树。
类追踪器Class Tracker:类跟踪视图可以包含任意数量的图表,显示选定的类和包的实例与时间。
分析:内存中的对象的情况
- 频繁创建的Java对象:死循环、循环次数过多
- 存在大的对象:读取文件时,byte[]应该边读边写。–> 如果长时间不写出的话,导致byte[]过大
- 存在内存泄漏
注意:
- All Objects后面的Size大小是浅堆大小
- Record Objects在判断内存泄露的时候使用,可以通过观察Telemetries中的Memory,如果里面出现垃圾回收之后的内存占用逐步提高,这就有可能出现内存泄露问题,所以可以使用Record Objects查看,但是该分析默认不开启,毕竟占用CPU性能太多
堆遍历 heap walker
如果通过内存视图 Live Memory已经分析出哪个类的对象不能进行垃圾回收,并且有可能导致内存溢出,如果想进一步分析,我们可以在该对象上点击右键,选择Show Selection In Heap Walker,如下图:
之后进行溯源,操作如下:
查看结果,并根据结果去看对应的图表:
以下是图表的展示情况:
对于堆快照:
cpu视图 cpu views
具体使用:
访问树
记录方法统计信息
方法统计
具体分析
线程视图 threads
JProfiler通过对线程历史的监控判断其运行状态,并监控是否有线程阻塞产生,还能将一个线程所管理的方法以树状形式呈现。对线程剖析。
- 线程历史Thread History:显示一个与线程活动和线程状态在一起的活动时间表。
- 线程监控Thread Monitor:显示一个列表,包括所有的活动线程以及它们目前的活动状况。
- 线程转储Thread Dumps:显示所有线程的堆栈跟踪。
线程分析主要关心三个方面:
- web容器的线程最大数。比如: Tomcat的线程容量应该略大于最大并发数。
- 线程阻塞
- 线程死锁
具体使用:
查看线程运行情况
新建线程dump文件
监视器&锁 Monitors&locks
- 监控和锁Monitors & Locks所有线程持有锁的情况以及锁的信息。
- 观察JVM的内部线程并查看状态:
- 死锁探测图表Current Locking Graph:显示JVM中的当前死锁图表。
- 目前使用的监测器CurrentMonitors:显示目前使用的监测器并且包括它们的关联线程。
- 锁定历史图表Locking History Graph:显示记录在JVM中的锁定历史。
- 历史检测记录MonitorHistory:显示重大的等待事件和阻塞事件的历史记录。
- 监控器使用统计Monitor Usage Statistics:显示分组监测,线程和监测类的统计监测数据
4、案例分析
1、案例1(较为安全)
代码:
1 | public class JProfilerTest { |
2、案例2(内存泄露)
代码:
1 | public class MemoryLeak { |
分析:
我们通过JProfiler来看一下,如下:
你可以看到内存一个劲的往上涨,但是就是没有下降的趋势,说明这肯定有问题,过不了多久就会出现OOM,我们来到Live memory中,先标记看一下到底是哪些对象在进行内存增长,等一小下看看会不会触发垃圾回收,如果不触发的话,我们自己来触发垃圾回收,之后观察哪些对象没有被回收掉,如下:
我上面点击了Mark Current,发现有些对象在持续增长,然后点击了一下Run GC,结果如下所示:
可以看出byte[]没有被回收,说明它是有问题的,我们点击Show Selection In Heap Walker,如下:
然后看一下该对象被谁引用,如下:
结果如下:
可以看出byte[]来自于Bean类是的list中,并且这个list是ArrayList类型的静态集合,所以找到了:static ArrayList list = new ArrayList();
发现list是静态的,这不妥,因为我们的目的是while结束之后Bean对象被回收,并且Bena对象中的所有字段都被回收,但是list是静态的,那就是类的,众所周知,类变量随类而生,随类而灭,因此每次我们往list中添加值,都是往同一个list中添加值,这会造成list不断增大,并且不能回收,所以最终会导致OOM
6、Arthas
1、基本概述
1、背景
前面,我们介绍了jdk自带的jvisualvm等免费工具,以及商业化工具Jprofiler。
jvisualvm界面:
Jprofiler界面:
这两款工具在业界知名度也比较高,他们的优点是可以图形界面上看到各维度的性能数据,使用者根据这些数据进行综合分析,然后判断哪里出现了性能问题。
但是这两款工具也有个缺点,都必须在服务端项目进程中配置相关的监控参数。然后工具通过远程连接到项目进程,获取相关的数据。这样就会带来一些不便,比如线上环境的网络是隔离的,本地的监控工具根本连不上线上环境。并且类似于Jprofiler这样的商业工具,是需要付费的。
那么有没有一款工具不需要远程连接,也不需要配置监控参数,同时也提供了丰富的性能监控数据呢?
今天跟大家介绍一款阿里巴巴开源的性能分析神器Arthas (阿尔萨斯)
2、概述
Arthas (阿尔萨斯)是Alibaba开源的Java诊断工具, 深受开发者喜爱。在线排查问题,无需重启;动态跟踪Java代码;实时监控JVM状态。
Arthas支持JDK 6+,支持Linux/Mac/Windows,采用命令行交互模式,同时提供丰富的Tab自动补全功能,进-步方便进行问题的定位和诊断。
当你遇到以下类似问题而束手无策时,Arthas可以帮助你解决:
- 这个类从哪个jar包加载的?为什么会报各种类相关的Exception?
- 我改的代码为什么没有执行到?难道是我没commit?分支搞错了?
- 遇到问题无法在线上debug,难道只能通过加日志再重新发布吗?
- 线上遇到某个用户的数据处理有问题,但线上同样无法debug,线下无法重现!
- 是否有一个全局视角来查看系统的运行状况?
- 有什么办法可以监控到JVM的实时运行状态?
- 怎么快速定位应用的热点,生成火焰图?
3、基于哪些工具开发而来
- greys-anatomy:Arthas代码基于Greys二次开发而来,非常感谢Greys之前所有的工作,以及Greys原作者对Arthas提出的意见和建议!
- termd:Arthas的命令行实现基于termd开发,是一款优秀的命令行程序开发框架,感谢termd提供了优秀的框架。
- crash:Arthas的文本渲染功能基于crash中的文本渲染功能开发,可以从这里看到源码,感谢crash在这方面所做的优秀工作。
- cli:Arthas的命令行界面基于vert.x提供的cli库进行开发,感谢vert. x在这方面做的优秀工作。
- compiler:Arthas里的内存编绎器代码来源
- Apache Commons Net:Arthas里的Telnet Client代码来源
- JavaAgent:运行在main方法之前的拦截器,它内定的方法名叫premain ,也就是说先执行premain方法然后再执行main方法
- ASM:一个通用的Java字节码操作和分析框架。它可以用于修改现有的类或直接以二进制形式动态生成类。ASM提供了一些常见的字节码转换和分析算法,可以从它们构建定制的复杂转换和代码分析工具。ASM提供了与其他Java字节码框架类似的功能,但是主要关注性能。因为它被设计和实现得尽可能小和快,所以非常适合在动态系统中使用(当然也可以以静态方式使用,例如在编译器中)
4、官方使用文档
https://arthas.aliyun.com/doc/quick-start.html
2、安装与使用
1、安装
安装方式一:可以直接在Linux 上通过命令下载
可以在官方Github 上进行下载,如果速度较慢,可以尝试国内的码云Gitee 下载。
github 下载
1
wget https://alibaba.github.io/arthas/arthas-boot.jar
Gitee下载
1
wget https://arthas.gitee.io/arthas-boot.jar
安装方式二:
也可以在浏览器直接访问,等待下载成功后,上传到Linux服务器上。(可以放在opt文件目录下)
卸载:
在Linux/Unix/Mac 平台
删除下面文件:
1
2rm -rf ~/.arthas/I
rm -rf ~/logs/arthasWindows平台直接删除user home下面的.arthas和logs/arthas目录
2、工程目录
- arthas-agent:基于JavaAgent技术的代理
- bin:一些启动脚本
- arthas-boot:Java版本的一键安装启动脚本
- arthas-client:telnet client代码
- arthas-common:一些共用的工具类和枚举类
- arthas-core:核心库,各种arthas命令的交互和实现
- arthas-demo:示例代码
- arthas-memorycompiler:内存编绎器代码,Fork from https://github.com/skalogs/SkaETL/tree/master/compiler
- arthas-packaging:maven打包相关的
- arthas-site:arthas站点
- arthas-spy:编织到目标类中的各个切面
- static:静态资源
- arthas-testcase:测试
3、启动
Arthas只是一个java程序,所以可以直接用java -jar 运行。
执行成功后,arthas提供了一种命令行方式的交互方式,arthas 会检测当前服务器上的Java进程,并将进程列表展示出来,用户输入对应的编号(1、2、3、4……)进行选择,然后回车。
比如:
方式1:
1
java -jar arthas-boot.jar
#选择进程(输入[]内编号(不是PID)回车)
[INFO] arthas-boot version: 3.1.4
[INFO] Found existing java process, please choose one and hit RETURN.* [1]: 11616 com.Arthas
[2]: 8676
[3]: 16200 org. jetbrains. jps . cmdline . Launcher
[4]: 21032 org. jetbrains. idea . maven. server . RemoteMavenServer
方式2:
运行时选择Java进程PID:
1
java -jar arthas-boot.jar [PID]
4、查看进程
jps
5、查看日志
cat ~/logs/arthas/arthas.log
6、查看帮助
java -jar arthas-boot.jar -h
7、web console
除了在命令行查看外,Arthas目前还支持Web Console。在成功启动连接进程之后就已经自动启动,可以直接访问http://127.0.0.1:8563/ 访问,页面上的操作模式和控制台完全一样。
8、退出
最后一行[arthas@7457]$, 说明打开进入了监控客户端,在这里就可以执行相关命令进行查看了。
- 使用
quit\exit
:退出当前客户端 - 使用
stop\shutdown
:关闭arthas服务端,并退出所有客户端。
3、相关诊断指令
1、基础指令
2、jvm相关
命令列表:https://arthas.aliyun.com/doc/commands.html#id1
dashboard(常用)
作用:当前系统的实时数据面板
options:
-i
:打印的时间间隔(单位:ms)使用:
1
dashboard -i 500
-n
:打印的次数使用:
1
dashboard -n 30
若没有填写options,则会在默认时间间隔下不断地打印
thread
- 链接:https://arthas.aliyun.com/doc/thread.html
- 作用:查看当前线程信息,查看线程的堆栈
jvm
- 链接:https://arthas.aliyun.com/doc/jvm
- 作用:查看jvm详细的性能数据
其他
- sysprop
- 链接:https://arthas.aliyun.com/doc/sysprop
- 作用:查看和修改JVM的系统属性
- sysenv
- 链接:https://arthas.aliyun.com/doc/sysenv
- 作用:查看JVM的环境变量
- getstatic
- 链接:https://arthas.aliyun.com/doc/getstatic
- 作用:查看类的静态属性
- heapdump
- 链接:https://arthas.aliyun.com/doc/heapdump
- 作用:类似jmap 命令的heap dump功能
- sysprop
3、class/classloader相关
sc
- 作用:查看JVM已加载的类信息
- 链接:https://arthas.aliyun.com/doc/sc
- 常用参数:
- class- pattern:类名表达式匹配
- -d:输出当前类的详细信息,包括这个类所加载的原始文件来源、类的声明、加载的ClassLoader等详细信息。如果一个类被多个ClassLoader所
加载,则会出现多次 - -E:开启正则表达式匹配,默认为通配符匹配
- -f:输出当前类的成员变量信息(需要配合参数-d一起使用)
- -x:指定输出静态变量时属性的遍历深度,默认为0,即直接使用toString输出
- 补充:
- class-pattern支持全限定名, 如com.test.AAA,也支持com/test/AAA这样的格式,这样,我们从异常堆栈里面把类名拷贝过来的时候,不需要在手动把
/
替换为.
了。 - sc默认开启了子类匹配功能,也就是说所有当前类的子类也会被搜索出来,想要精确的匹配,请打开options disable-sub-class true开关
- class-pattern支持全限定名, 如com.test.AAA,也支持com/test/AAA这样的格式,这样,我们从异常堆栈里面把类名拷贝过来的时候,不需要在手动把
sm
- 作用:查看己加载类的方法信息
- 链接:https://arthas.aliyun.com/doc/sm
- sm命令只能看到由当前类所声明(declaring) 的方法,父类则无法看到。
- 常用参数:
- class-pattern:类名表达式匹配
- method-pattern:方法名表达式匹配
- -d:展示每个方法的详细信息
- -E:开启正则表达式匹配,默认为通配符匹配
jad
作用:反编译指定己加载类的源码
在Arthas Console 上,反编译出来的源码是带语法高亮的,阅读更方便
当然,反编译出来的java代码可能会存在语法错误,但不影响你进行阅读理解
编译java.lang.String
mc、redefine
mc命令:Memory Compiler/内存编译器,编译.java文件生成.class
使用:
1
mc /tmp/Test.java
redefine命令:加载外部的.class文件,redefine jvm已加载的类。
推荐使用retransform命令
1
2redefine /tmp/Test.class
redefine -c 327a647b /tmp/Test.class /tmp/Test\$Inner.class
classloader
- 作用:查看classloader 的继承树,urls,类加载信息
- 链接:https://arthas.aliyun.com/doc/classloader
- 了解当前系统中有多少类加载器,以及每个加载器加载的类数量,帮助您判断是否有类加载器泄漏。
- 常用参数:
- -t:查看ClassLoader的继承树
- -l:按类加载实例查看统计信息
- -c:用classloader对应的hashcode来查看对应的jar urls
4、monitor/watch/trace相关
命令列表:https://arthas.aliyun.com/doc/commands.html#id1
monitor
monitor命令:方法执行监控
对匹配class-pattern / method-pattern的类、方法的调用进行监控。涉及方法的调用次数、执行时间、失败率等
monitor命令是一个非实时返回命令
常用参数:
- class-pattern:类名表达式匹配
- method-pattern:方法名表达式匹配
- -c:统计周期,默认值为120秒
监控项:
watch
- watch命令:方法执行数据观测
- 链接:https://arthas.aliyun.com/doc/watch
- 作用:让你能方便的观察到指定方法的调用情况。能观察到的范围为:返回值、抛出异常、入参,通过编写groovy表达式进行对应变量的查看。
- 常用参数:
- class-pattern:类名表达式匹配
- method-pattern:方法名表达式匹配
- express:观察表达式
- condition-express:条件表达式
- -b:在方法调用之前观察(默认关闭)
- -e:在方法异常之后观察(默认关闭)
- -s:在方法返回之后观察(默认关闭)
- -f:在方法结束之后(正常返回和异常返回)观察(默认开启)
- -x:指定输出结果的属性遍历深度,默认为0
- #cost:方法执行耗时
- 说明:这里重点要说明的是观察表达式,观察表达式的构成主要由ognl 表达式组成,所以你可以这样写”{params, returnObj}”,只要是一个合法的ognl表达式,都能被正常支持。
- 举例:watch全限定类名 方法名returnObj
trace
- trace命令:方法内部调用路径,并输出方法路径上的每个节点上耗时
- 链接:https://arthas.aliyun.com/doc/trace
- 补充说明:
- trace命令能主动搜索class-pattern / method- pattern 对应的方法调用路径,渲染和统计整个调用链路上的所有性能开销和追踪调用链路。
- trace能方便的帮助你定位和发现因RT高而导致的性能问题缺陷,但其每次只能跟踪一级方法的调用链路
- trace 在执行的过程中本身是会有一定的性能开销,在统计的报告中并未像JProfiler一样预先减去其自身的统计开销。所以这统计出来有些许的不准,渲染路径上调用的类、方法越多,性能偏差越大。但还是能让你看清一些事情的。
- 参数说明:
- class-pattern:类名表达式匹配
- method-pattern:方法名表达式匹配
- condition-express:条件表达式
- -n:命令执行次数
- #cost:方法执行耗时
stack
- stack命令:输出当前方法被调用的调用路径
- 链接:https://arthas.aliyun.com/doc/stack
- 常用参数:
- class-pattern:类名表达式匹配
- method-pattern:方法名表达式匹配
- condition-express:条件表达式
- -n:执行次数限制
- #cost:方法执行耗时
tt
- tt命令:方法执行数据的时空隧道,记录下指定方法每次调用的入参和返回信息,并能对这些不同的时间下调用进行观测。
- 链接:https://arthas.aliyun.com/doc/tt
- TimeTunnel的缩写
- 常用参数:
- -t:表明希望记录下类 *Test 的 print 方法的每次执行情况。
- -n 3:指定你需要记录的次数,当达到记录次数时Arthas会主动中断tt命令的记录过程,避免人工操作无法停止的情况。
- -s:筛选指定方法的调用信息
- -i:参数后边跟着对应的INDEX编号查看到它的详细信息
- -p:重做一次调用,通过
--replay-times
指定调用次数,通过--replay- interval
指定多次调用间隔(单位ms,默认1000ms)
5、其他
使用>将结果重写到日志文件,使用&指令命令是后台运行,session断开不影响任务执行(生命周期默认为1天)
- jobs:列出所有job
- kill:强制终止任务
- fg:将暂停的任务拉到前台执行
- bg:将暂停的任务放到后台执行
- grep:搜索满足条件的结果
- plaintext:将命令的结果去除ANSI颜色
- wc:按行统计输出结果
- options:查看或设置Arthas全局开关
- profiler:使用async-profiler对应用采样,生成火焰图
7、Java Misssion Control
1、历史
在Oracle 收购Sun之前,Oracle 的JRockit 虚拟机提供了一款叫做JRockitMission Control的虛拟机诊断工具。
在Oracle收购Sun之后,Oracle公司同时拥有了Sun Hotspot和JRockit两款虚拟机。根据Oracle对于Java的战略,在今后的发展中,会将JRockit的优秀特性移植到Hotspot上。其中一个重要的改进就是在Sun的JDK中加入了JRockit的支持。
在Oracle JDK 7u40之后,Mission Control这款工具已经绑定在Oracle JDK中发布。
自Java 11开始,本节介绍的JFR已经开源。但在之前的Java版本,JFR属于Commercial Feature,可要通过Java虚拟机参数-XX: +UnlockCommercialFeatures
开启。
如果你有兴趣请可以查看OpenJDK的Mission Control项目。
2、启动
Mission Control位于%JAVA_ HOME%/bin/jmc.exe,打开这款软件。
3、概述
Java Mission Control (简称JMC) Java官方提供的性能强劲的工具,是一个用于对Java 应用程序进行管理、监视、概要分析和故障排除的工具套件。
它包含一个GUI客户端,以及众多用来收集Java虚拟机性能数据的插件,如JMX Console(能够访问用来存放虚拟机各个子系统运行数据的MXBeans
),以及虚拟机内置的高效profiling 工具Java Flight Recorder (JFR)
。
JMC 的另一个优点就是:采用取样,而不是传统的代码植入技术,对应用性能的影响非常非常小,完全可以开着JMC来做压测(唯一影响可能是full gc多了)。
4、功能:实时监控JVM运行时的状态
如果是远程服务器,使用前要开JMX。
-Dcom. sun. management . jmxremote . port=${YOUR PORT}
-Dcom. sun. management . jmxremote
-Dcom. sun. management . imxremote . authenticate=false
-Dcom. sun . management . jmxremote. ss1=false
-Djava. rmi. server . hostname=${YOUR HOST/IP}
文件 -> 连接 ->创建新连接,填入上面JMX参数的host和port
5、Java Flight Recorder
Java Flight Recorder是JMC 的其中一个组件。
Java Flight Recorder能够以极低的性能开销收集Java虚拟机的性能数据。
JFR的性能开销很小,在默认配置下平均低于1%**。与其他工具相比,JFR能够直接访问虚拟机内的数据,并且不会影响虚拟机的优化。因此,它非常适用于生产环境下满负荷运行的Java程序**。
Java Flight Recorder和JDK Mission Control共同创建了一个完整的工具链。JDK Mission Control可对Java Flight Recorder连续收集低水平和详细的运行时信息进行高效详细的分析。
事件类型
- 当启用时,JFR 将记录运行过程中发生的一系列事件。其中包括Java层面的事件,如线程事件、锁事件,以及Java 虚拟机内部的事件,如新建对象、垃圾回收和即时编译事件。
- 按照发生时机以及持续时间来划分,JFR 的事件共有四种类型,它们分别为以下四种:
- **瞬时事件(Instant Event)**:用户关心的是它们发生与否,例如异常、线程启动事件。
- **持续事件(Duration Event)**:用户关心的是它们的持续时间,例如垃圾回收事件。
- **计时事件(Timed Event)**:是时长超出指定阈值的持续事件。
- **取样事件(Sample Event)**:是周期性取样的事件。
- 取样事件的其中一个常见例子便是方法抽样(Method Sampling),即每隔一段时间统计各个线程的栈轨迹。如果在这些抽样取得的栈轨迹中存在一个反复出现的方法,那么我们可以推测该方法是热点方法。
启动方式
方式1:使用
-XX:StartFlightRecording=参数
比如:下面命令中,JFR将会在Java虚拟机启动5s后(对应delay=5s)收集数据,持续20s (对应duration=20s)。当收集完毕后,JFR 会将收集得到的数据保存至指定的文件中(对应filename=myrecording . jfr)
1
2java
-XX:StartFlightRecording=delay=5s,duration=20s,filename=myrecording.jfr,setting s=profile MyApp由于JFR将持续收集数据,如果不加以限制,那么JFR可能会填满硬盘的所有空间。因此,我们有必要对这种模式下所收集的数据进行限制。
比如:
1
java -XX:StartFlightRecording=maxage=10m, maxsize=100m, name=SomeLabel MyApp
方式2:使用
jcmd的JFR.*子命令
通过jcmd来让JFR开始收集数据、停止收集数据,或者保存所收集的数据,对应的子命令分别为
JFR.start
、JFR.stop
以及JFR.dump
。1
jcmd <PID> JFR.start settings=profile maxage=10m maxsize=150m name=SomeLabel
上述命令运行过后,目标进程中的JFR已经开始收集数据。此时,我们可以通过下述命令来导出己经收集到的数据:
1
jcmd <PID> JFR.dump name=SomeLabel filename=myrecording.jfr
最后,我们可以通过下述命令关闭目标进程中的JFR:
1
jcmd <PID> JFR.stop name=SomeLabel
方式3:JMC的
JFR插件
具体使用:
启动飞行记录仪
启动飞行记录
正式启动
Java Flight Recorder 取样分析
要采用取样,必须先添加参数:
-XX: +UnlockCommercialFeatures
-XX: +Flight Recorder
否则:
取样时间默认1分钟,可自行按需调整,事件设置选为profiling,然后可以设置取样profile哪些信息,比如:
- 加上对象数量的统计:Java Virtual Machine -> GC -> Detailed -> Object Count/Object Count after GC
- 方法调用采样的间隔从10ms改为1ms(但不能低于 1ms,否则会影响性能了):Java Virtual Machine -> Profiling -> Method Profiling Sample/Method Sampling Information
- Socket与File采样,10ms 太久,但即使改为1ms也未必能抓住什么,可以干脆取消掉:Java Application -> File Read/FileWrite/Socket Read/Socket Write
然后就开始Profile,到时间后Profile 结束,会自动把记录下载回来,在JMC中展示。
从展示信息中,我们大致可以读到内存和CPU信息、代码、线程和IO等比较重要的信息展示。
代码:
1 | /** |
结果:
一般信息
内存
代码
线程
I/O
系统
事件
8、其他工具
1、Flame Graphs(火焰图)
在追求极致性能的场景下,了解你的程序运行过程中cpu在干什么很重要,火焰图就是一种非常直观的展示cpu在程序整个生命周期过程中时间分配的工具。
火焰图对于现代的程序员不应该陌生,这个工具可以非常直观的显示出调用栈中的CPU消耗瓶颈。
网上的关于java火焰图的讲解大部分来自于Brendan Gregg的博客
火焰图简单通过x轴横条宽度来度量时间指标,y轴代表线程栈的层次。
2、Tprofiler
- 案例:
- 使用JDK自身提供的工具进行JVM调优可以将TPS由2.5提升到20(提升了7倍),并准确定位系统瓶颈
- 系统的瓶颈有:应用里静态对象不是很多、有大量的业务进程在频繁创建一些生命周期很长的临时对象,代码里有问题
- 那么,如何在海量业务代码里边准确定位这些性能代码?这里使用阿里开源工具Tprofiler来定位这些性能代码,成功解决掉GC过于频繁的性能瓶颈,并最终在上次优化的基础上将TPS在提升了4倍,即提升到100。
- TProfiler配置部署、远程操作、日志阅读都不太复杂,操作还是很简单的。但是其却是能够起到一针见血、立竿见影的效果,帮我们解决了GC过于频繁的性能瓶颈。
- TProfiler最重要的特性就是能够统计出你指定时间段内JVM 的top method,这些top method极有可能就是造成你JVM 性能瓶颈的元凶。这是其他大多数JVM调优工具所不具备的,包括JRockit Mission Control。JRokit 首席开发者Marcus Hirt在其私人博客《Low Overhead Method Profiling with Java Mission Control》下的评论中曾明确指出JRMC并不支持TOP方法的统计。
- TProfiler的下载
3、Btrace
- Java运行时追踪工具
- 常见的动态追踪工具有BTrace、HouseMD (该项目已经停止开发)、Greys-Anatomy (国人开发,个人开发者)、Byteman (JBoss出品),注意Java运行时追踪工具并不限于这几种,但是这几个是相对比较常用的。
- BTrace是SUN Kenai云计算开发平台下的一个开源项目,旨在为java提供安全可靠的动态跟踪分析工具。先看一下BTrace的官方定义:
- BTrace is a safe, dynamic tracing tool for the Java platform. BTrace can be used to dynamically trace a running Java program (similar to DTrace for OpenSolaris applications and OS). BTrace dynamically instruments the classes of the target application to inject tracing code (“bytecode tracing”)。
- 简洁明了,大意是一个Java平台的安全的动态追踪工具。可以用来动态地追踪一个运行的Java程序。BTrace动态调整目标应用程序的类以注入跟踪代码(“字节码跟踪”)。
4、YourKit
5、JProbe
6、Spring Insight
9、学习建议
Visual VM -> Arthus -> Jproflier(公司有能购买商业版) -> MAT
10、补充1:再谈内存泄露
1、内存泄露的理解与分析
1、何为内存泄漏( memory leak)
可达性分析算法来判断对象是否是不再使用的对象,本质都是判断一个对象是否还被引用。那么对于这种情况下,由于代码的实现不同就会出现很多种内存泄漏问题(让JVM误以为此对象还在引用中,无法回收,造成内存泄漏)。
- 是否还被使用?是
- 是否还被需要?否
2、内存泄漏( memory leak) 的理解
严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。
但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。
对象X引用对象Y,X的生命周期比Y的生命周期长;那么当Y生命周期结束的时候,X依然引用着Y,这时候,垃圾回收期是不会回收对象Y的;如果对象X还引用着生命周期比较短的A、B、C,对象A又引用着对象a、b、c,这样就可能造成大量无用的对象不能被回收,进而占据了内存资源,造成内存泄漏,直到内存溢出。
3、内存泄漏与内存溢出的关系
内存泄漏(memory leak)
申请了内存用完了不释放,比如一共有1024M的内存,分配了512M 的内存一直不回收,那么可以用的内存只有512M了, 仿佛泄露掉了一部分;通俗点讲的话, 内存泄漏就是 [占着茅坑不拉shi] 。
内存溢出(out of memory)
申请内存时,没有足够的内存可以使用;通俗一点儿讲,一个厕所就三个坑,有两个站着茅坑不走的(内存泄漏),剩下最后一个坑,厕所表示接待压力很大,这时候一下子来了两个人,坑位(内存)就不够了,内存泄漏变成内存溢出了。
可见,内存泄漏和内存溢出的关系:内存泄漏的增多,最终会导致内存溢出。
4、泄漏的分类
- 经常发生:发生内存泄露的代码会被多次执行,每次执行,泄露一块内存;(坚决杜绝)
- 偶然发生:在某些特定情况下才会发生;
- 一次性:发生内存泄露的方法只会执行一次;
- 隐式泄漏:一直占着内存不释放,直到执行结束;严格的说这个不算内存泄漏,因为最终释放掉了,但是如果执行时间特别长,也可能会导致内存耗尽。
2、Java中内存泄露的8种情况
静态集合类
静态集合类,如HashMap、LinkedList等等。如果这些容器为静态的,那么它们的生命周期与JVM程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。简单而言,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。
代码
1
2
3
4
5
6
7public class MemoryLeak {
static List list = new ArrayList();
public void oomTests() {
Object obj = new Object();
list.add(obj);
}
}
单例模式
- 单例模式,和静态集合导致内存泄露的原因类似,因为单例的静态特性,它的生命周期和JVM的生命周期一样长,所以如果单例对象如果持有外部对象的引用,那么这个外部对象也不会被回收,那么就会造成内存泄漏。
内部类持有外部类
- 内部类持有外部类,如果个外部类的实例对象的方法返回了一个内部类的实例对象。这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄漏。
各种连接,如数据库连接、网络连接和IO连接等
各种连接,如数据库连接、网络连接和IO连接等。在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用close方法来释放与数据库的连接。只有连接被关闭后,垃圾回收器才会回收对应的对象。否则,如果在访问数据库的过程中,对Connection、 Statement或ResultSet 不显性地关闭,将会造成大量的对象无法被回收,从而引起内存泄漏。
代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14public static void main(String[] args) {
try {
Connection conn = null;
Class.forName ("com.mysq1.jdbc.Driver");
conn = DriverManager . getConnection("url", 11);
Statement stmt = conn. createStatement();
ResultSet rs = stmt.executeQuery("....");
} catch (Exception e) {
//异常日志
} finally {
//1.关闭结果集Statement
// 2.关闭声明的对象ResultSet
// 3.关闭连接Connection
}
变量不合理的作用域
变量不合理的作用域。一般而言,一个变量的定义的作用范围大于其使用范围,很有可能会造成内存泄漏。另一方面,如果没有及时地把对象设置为null,很有可能导致内存泄漏的发生。
伪代码:
1
2
3
4
5
6
7public class UsingRandom {
private String msg;
public void receiveMsg(){
readFromNet();//从网络中接受数据保存到msg中
saveDB();//把msg保存到数据库中
}
}如上面这个伪代码,通过readFromNet方法把接受的消息保存在变量msg中,然后调用saveDB方法把msg的内容保存到数据库中,此时msg已经就没用了,由于msg的生命周期与对象的生命周期相同,此时msg还不能回收,因此造成了内存泄漏。
实际上这个msg变量可以放在receiveMsg方法内部, 当方法使用完,那么msg的生命周期也就结束,此时就可以回收了。还有一种方法,在使用完msg后,把msg设置为null,这样垃圾回收器也会回收msg的内存空间。
解决方法1:
1
2
3
4
5
6
7public class UsingRandom {
public void receiveMsg(){
private String msg;
msg = readFromNet();//从网络中接受数据保存到msg中
saveDB();//把msg保存到数据库中
}
}解决方法2:
1
2
3
4
5
6
7
8public class UsingRandom {
private String msg;
public void receiveMsg(){
readFromNet();//从网络中接受数据保存到msg中
saveDB();//把msg保存到数据库中
msg = null;
}
}
改变哈希值
改变哈希值,当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了。
否则,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄漏。
这也是String为什么被设置成了不可变类型,我们可以放心地把String存入HashSet,或者把String当做HashMap的key值;
当我们想把自己定义的类保存到散列表的时候,需要保证对象的hashCode不可变。
代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53public class ChangeHashCode {
public static void main(String[] args) {
HashSet set = new HashSet();
Person p1 = new Person(1001, "AA");
Person p2 = new Person(1002, "BB");
set.add(p1);
set.add(p2);
p1.name = "CC";//导致了内存的泄漏
set.remove(p1); //删除失败
System.out.println(set);
set.add(new Person(1001, "CC"));
System.out.println(set);
set.add(new Person(1001, "AA"));
System.out.println(set);
}
}
class Person {
int id;
String name;
public Person(int id, String name) {
this.id = id;
this.name = name;
}
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person person = (Person) o;
if (id != person.id) return false;
return name != null ? name.equals(person.name) : person.name == null;
}
public int hashCode() {
int result = id;
result = 31 * result + (name != null ? name.hashCode() : 0);
return result;
}
public String toString() {
return "Person{" +
"id=" + id +
", name='" + name + '\'' +
'}';
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48public class ChangeHashCode1 {
public static void main(String[] args) {
HashSet<Point> hs = new HashSet<Point>();
Point cc = new Point();
cc.setX(10);//hashCode = 41
hs.add(cc);
cc.setX(20);//hashCode = 51 此行为导致了内存的泄漏
System.out.println("hs.remove = " + hs.remove(cc));//false
hs.add(cc);
System.out.println("hs.size = " + hs.size());//size = 2
System.out.println(hs);
}
}
class Point {
int x;
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + x;
return result;
}
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null) return false;
if (getClass() != obj.getClass()) return false;
Point other = (Point) obj;
if (x != other.x) return false;
return true;
}
public String toString() {
return "Point{" +
"x=" + x +
'}';
}
}
缓存泄露
内存泄漏的另一个常见来源是缓存,一旦你把对象引用放入到缓存中,他就很容易遗忘。比如:之前项目在一次上线的时候,应用启动奇慢直到夯死,就是因为代码中会加载一个表中的数据到缓存(内存)中,测试环境只有几百条数据,但是生产环境有几百万的数据。
对于这个问题,可以使用WeakHashMap代表缓存,此种Map的特点是,当除了自身有对key的引用外,此key没有其他引用,那么此map会自动丢弃此值。
代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56public class MapTest {
static Map wMap = new WeakHashMap();
static Map map = new HashMap();
public static void main(String[] args) {
init();
testWeakHashMap();
testHashMap();
}
public static void init() {
String ref1 = new String("obejct1");
String ref2 = new String("obejct2");
String ref3 = new String("obejct3");
String ref4 = new String("obejct4");
wMap.put(ref1, "cacheObject1");
wMap.put(ref2, "cacheObject2");
map.put(ref3, "cacheObject3");
map.put(ref4, "cacheObject4");
System.out.println("String引用ref1,ref2,ref3,ref4 消失");
}
public static void testWeakHashMap() {
System.out.println("WeakHashMap GC之前");
for (Object o : wMap.entrySet()) {
System.out.println(o);
}
try {
System.gc();
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("WeakHashMap GC之后");
for (Object o : wMap.entrySet()) {
System.out.println(o);
}
}
public static void testHashMap() {
System.out.println("HashMap GC之前");
for (Object o : map.entrySet()) {
System.out.println(o);
}
try {
System.gc();
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("HashMap GC之后");
for (Object o : map.entrySet()) {
System.out.println(o);
}
}
}结果:
- String引用ref1,ref2,ref3,ref4 消失
- WeakHashMap GC之前
- obejct2=cacheObject2
- obejct1=cacheObject1
- WeakHashMap GC之后
- HashMap GC之前
- obejct4=cacheObject4
- obejct3=cacheObject3
- Disconnected from the target VM, address: ‘127.0.0.1:51628’, transport: ‘socket’
- HashMap GC之后
- obejct4=cacheObject4
- obejct3=cacheObject3
分析:
- 上面代码和图示主演演示WeakHashMap如何自动释放缓存对象,当init函数执行完成后,局部变量字符串引用weakd1、weakd2、d1、d2都会消失,此时只有静态map中保存中对字符串对象的引用,可以看到,调用gc之后,HashMap的没有被回收,而WeakHashMap里面的缓存被回收了。
监听器和回调
- 内存泄漏另一个常见来源是监听器和其他回调,如果客户端在你实现的API中注册回调,却没有显示的取消,那么就会积聚。
- 需要确保回调立即被当作垃圾回收的最佳方法是只保存它的弱引用,例如将他们保存成为WeakHashMap中的键。
3、内存泄露案例分析
1、案例代码
1 | public class Stack { |
2、分析
上述程序并没有明显的错误,但是这段程序有一个内存泄漏,随着GC活动的增加,或者内存占用的不断增加,程序性能的降低就会表现出来,严重时可导致内存泄漏,但是这种失败情况相对较少。
代码的主要问题在pop函数, 下面通过这张图示展现
假设这个栈一直增长,增长后如下图所示:
当进行大量的pop操作时,由于引用未进行置空,gc是不会释放的,如下图所示:
从上图中看以看出,如果栈先增长,在收缩,那么从栈中弹出的对象将不会被当作垃圾回收,即使程序不再使用栈中的这些队象,他们也不会回收,因为栈中仍然保存这对象的引用,俗称过期引用,这个内存泄露很隐蔽。
3、解决办法
将代码中的pop()方法变成如下方法:
1 | public Object pop() { |
一旦引用过期,清空这些引用,将引用置空。
4、案例代码(与移动端的开发有关)
1 | public class TestActivity extends Activity { |
5、分析
内部类持有外部类:当GC要回收TestActivity的时候,发现内部类(匿名线程)内部持有了外部类(key对象),不能将TestActivity顺利回收,导致了内存泄露。
6、解决方法
- 使用线程时,一定要确保线程在周期性对象(如Activity) 销毁时能正常结束, 如能正常结束,但是Activity销毁后还需执行一段时间,也可能造成泄露,此时可采用WeakReference方法来解决,另外在使用Handler的时候,如存在Delay操作,也可以采用WeakReference;
- 使用Handler + HandlerThread时, 记住在周期性对象销毁时调用looper.quit()方法;
11、补充2:支持使用OQL语言查询对象信息
1、介绍
MAT支持一种类似于SQL的查询语言OQL (Object Query Language) 。OQL使用类SQL语法,可以在堆中进行对象的查找和筛选。
2、在Eclipse MAT中如何用
3、例子
- select * from java.util.ArrayList(列出所有的ArrayList对象信息)
- select v.elementData from java.util.ArrayList v(注意:elementData代表ArrayList底层的数组,结果最终以数组形式将结果呈现出来)
- select objects v.elementData from java.util.ArrayList v(注意:elementData代表ArrayList底层的数组,objects代表对象类型,所以最终以对象形式将结果呈现出来,同时展示出来的还有浅堆、深堆)
- select as retained set * from com.atguigu.mat.Student(得到对象的保留级)
- select * from 0x6cd57c828(0x6cd57c828是Student类的地址值)
- select * from char[] s where s.@length > 10(char型数组长度大于10的数组)
- select * from java.lang.String s where s.value != null(字符串值不为空的字符串信息)
- select toString(f.path.value) from java.io.File f(列出文件的路径值)
- select v.elementData.@length from java.util.ArrayList v(列出Arraylist对象中ArrayList中的数组长度)
4、SELECT子句
在MAT中,Select子句的格式与SQL基本一致,用于指定要显示的列。Select子句中可以使用*
,查看结果对象的引用实例(相当于outgoing references) 。
SELECT * FROM java.util.Vector v
使用”OBJECTS
“关键字,可以将返回结果集中的项以对象的形式显示。
- SELECT objects v.elementData FROM java.util.Vector v
- SELECT OBJECTS s.value FROM java.lang.String s
在Select子句中,使用”AS RETAINED SET
“关键字可以得到所得对象的保留集。
- SELECT AS RETAINED SET * FROM com.atguigu.mat.Student
“DISTINCT”关键字用于在结果集中去除重复对象。
- SELECT DISTINCT OBJECTS classof(s) FROM java.lang.String s
5、FROM子句
From子句用于指定查询范围,它可以指定类名、正则表达式或者对象地址。
SELECT * FROM java.lang.StrIng s
下例使用正则表达式,限定搜索范围,输出所有com. atguigu包下所有类的实例
- SELECT * FROM “com\.atguigu\..*”
也可以直接使用类的地址进行搜索。使用类的地址的好处是可以区分被不同ClassLoader加载的同一种类型。
- select * from 0x37a0b4d
6、WHERE子句
Where子句用于指定OQL的查询条件。OQL查询将只返回满足Where子句指定条件的对象。Where子句的格式与传统SQL极为相似。
下例返回长度大于10的char数组。
- SELECT * FROM char[] s WHERE s.@length>10
下例返回包含”java”子字符串的所有字符串,使用”LIKE”操作符,”LIKE”操作符的操作参数为正则表达式。
- SELECT * FROM java.lang.String s WHERE toString(s) LIKE “. *java. *”
下例返回所有value域不为null的字符串,使用”=”操作符。
- SELECT * FROM java.lang.String s where s. value!=null
Where子句支持多个条件的AND、OR运算。下例返回数组长度大于15,并且深堆大于1000字节的所有Vector对象。
- SELECT * FROM java.util.Vector v WHERE v.elementData.@length>15 AND v. @retainedHeapSize>1000
7、内置对象与方法
OQL中可以访问堆内对象的属性,也可以访问堆内代理对象的属性。访问堆内对象的属性时,格式如下:
- [
. ] . . - 其中alias为对象名称。
访问java.io.File对象的path属性,并进一步访问path的value属性:
- SELECT toString(f.path.value) FROM java.io.File f
下例显示了String对象的内容、objectid和objectAddress。
- SELECT s.toString(), s.@objectId, s.@objectAddress FROM java.lang.String s
下例显示java.util.Vector内部数组的长度。
- SELECT v.elementData.@length FROM java.util.Vector v
下例显示了所有的java.util.Vector对象及其子类型
- select * from INSTANCEOF java.util.Vector
4、JVM运行时参数
1、JVM参数选项
1、类型一:标准参数选项
1、特点
- 比较稳定,后续版本基本不会变化
- 以
-
开头
2、各种选项
直接在DOS窗口中运行java或者java -help可以看到所有的标准选项
3、补充内容:-server与-client
Hotspot JVM有 两种模式,分别是server
(C2编译器)和client
(C1编译器),分别通过-server和-client模式设置
在32位Windows系统上,默认使用Client类型的JVM。要想使用Server模式,则机器配置至少有2个以上的CPU和2G以上的物理内存。client模式适用于对内存要求较小的桌面应用程序,默认使用Serial串行垃圾收集器
64位机器上
只支持server模式
的JVM,适用于需要大内存的应用程序,默认使用并行垃圾收集器关于server和client的官网介绍
对于以上第2点,我们可以打开DOS窗口,输入java -version就可以看到64位机器上用的server模式,如下所示:
2、类型二:-X参数选项
1、特点
- 非标准化参数
- 功能还是比较稳定的。但官方说后续版本可能会变更
- 以
-X
开头
2、各种选项
直接在DOS窗口中运行java -X命令可以看到所有的X选项
其中
- -Xmixed 混合模式执行 (默认)
- -Xint 仅解释模式执行
- -Xcomp 仅采用即时编译器模式
3、JVM的JIT编译模式相关的选项
-Xint
- 只使用解释器:所有字节码都被解释执行,这个模式的速度是很慢的
-Xcomp
- 只使用编译器:所有字节码第一次使用就被编译成本地代码,然后在执行
-Xmixed(默认)
混合模式:这是默认模式,刚开始的时候使用解释器慢慢解释执行,后来让JIT即时编译器根据程序运行的情况,有选择地将某些热点代码提前编译并缓存在本地,在执行的时候效率就非常高了。
默认使用的就是这种模式,证明如下:
4、特别地:-Xmx -Xms -Xss属于XX参数?
单位分别是:k/K、m/M、g/G
设置:-Xmx、-Xms最好设置成一样的值,避免扩容带来的损耗
-Xms
设置初始Java堆大小,等价于
-XX:InitialHeapSize
查看该参数值的时候,应该使用InitialHeapSize,例如jinfo flag InitialHeapSize 进程id
等价证明:
-Xmx
设置最大Java堆大小,等价于
-XX:MaxHeapSize
查看该参数值的时候,应该使用MaxHeapSize,例如jinfo flag InitialHeapSize 进程id
等价证明:
-Xss
- 设置Java线程堆栈大小,等价于
-XX:ThreadStackSize
- 查看该参数值的时候,应该使用ThreadStackSize,例如jinfo flag InitialHeapSize 进程id
- 设置Java线程堆栈大小,等价于
3、类型三:-XX参数选项(重要)
1、特点
- 非标准化参数
- 使用的最多的参数类型
- 这类选项属于实验性,不稳定
- 以
-XX
开头
2、作用
用于开发和调试JVM
3、分类
- Boolean类型格式
- -XX:+
- -XX:-
- 举例:
-XX:+UseParallelGC
:选择垃圾收集器为并行收集器-XX:+UseG1GC
:表示启用G1收集器-XX:+UseAdaptiveSizePolicy
:自动选择年轻代区大小和相应的Survivor区比例
- 说明:因为有的指令默认是开启的,所以可以使用
-
关闭
- 非Boolean类型格式(key-value类型)
- 子类型1:数值型格式-XX:
- 子类型2:非数值型格式-XX:
= - 例如:
-XX:HeapDumpPath=/usr/local/heapdump.hprof
:用来指定heap转存文件的存储路径。
- 例如:
4、特别地:-XX:+PrintFlagsFinal
- 输出所有参数的名称和默认值
- 默认不包括Diagnostic和Experimental的参数
- 可以配合
-XX:+UnlockDiagnosticVMOptions
和-XX:UnlockExperimentalVMOptions
使用
2、添加JVM参数选项
1、Eclipse
在空白处单击右键,选择Run As,在选择Run Configurations……
设置虚拟机参数
2、IDEA
Edit Configurations…
设置虚拟机参数
3、运行jar包
- 这是在java -jar demo.jar中的java -jar之间添加了虚拟机配置信息
- java -Xms50m -Xmx50m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -jar demo.jar
4、通过Tomcat运行war包
- Linux系统下可以在tomcat/bin/catalina.sh中添加类似如下配置:
- JAVA_OPTS=”-Xms512M -Xmx1024M”
- Windows系统下载catalina.bat中添加类似如下配置:
- set “JAVA_OPTS=-Xms512M -Xmx1024M”
5、程序运行过程中
jinfo不仅可以查看运行时某一个Java虛拟机参数的实际取值,甚至可以在运行时修改部分参数,并使之立即生效。
但是,并非所有参数都支持动态修改。参数只有被标记为manageable的flag可以被实时修改。其实,这个修改能力是极其有限的。
#可以查看被标记为manageable的参数:java -XX:+PrintFlagsFinal -version | grep manageable
- 使用jinfo -flag
= 设置非Boolean类型参数 - 使用jinfo -flag [+|-]
设置Boolean类型参数
3、常用的JVM参数选项
1、打印设置的XX选项及值
-XX:+PrintCommandLineFlags
:可以让程序运行前打印出用户手动设置或者JVM自动设置的XX选项-XX:+PrintFlagsInitial
:**
-XX:+PrintFlagsFinal
**:表示打印出XX选项在运行程序时生效的值如果值的前面加上了:=,说明该值不是初始值,该值可能被jvm自动改变了,也可能被我们设置的参数改变了,如下所示:
有一些被改变的值是项目在启动过程中,系统帮我们修改的
注意区别:
-XX:+PrintFlagsFinal是打印出所有XX选项在运行程序时生效的值
jinfo -flag 参数名称 进程id
**:查看某个java进程的具体**参数信息进程id可以通过jps命令查看具体操作如下:(其中3540代表进程id)
-XX:+PrintVMOptions
:打印JVM的参数
2、堆、栈、方法区等内存大小设置
1、栈
-Xss128k
- 设置每个线程的栈大小为128k
- 等价于
-XX:ThreadStackSize
2、堆内存
-Xms3550m
- 等价于
-XX:InitialHeapSize
,设置JVM初始堆内存为3500M
- 等价于
-Xmx3550m
- 等价于
-XX:MaxHeapSize
,设置JVM最大堆内存为3500M
- 等价于
-Xmn2g
- 设置年轻代大小为2G,即等价于
-XX:NewSize=2g
-XX:MaxNewSize=2g
,也就是设置年轻代初始值和年轻代最大值都是2G - 官方推荐配置为整个堆大小的3/8
- 设置年轻代大小为2G,即等价于
-XX:NewSize=1024m
- 设置年轻代初始值为1024M
-XX:MaxNewSize=1024m
- 设置年轻代最大值为1024M
-XX:SurvivorRatio=8
- 设置年轻代中Eden区与一个Survivor区的比值,默认为8
- 只有显示使用Eden区和Survivor区的比例,才会让比例生效,否则比例都会自动设置,至于其中的原因,请看下面的-XX:+UseAdaptiveSizePolicy中的解释,最后推荐使用默认打开的-XX:+UseAdaptiveSizePolicy设置,并且不显示设置-XX:SurvivorRatio
-XX:+UseAdaptiveSizePolicy
- 自动选择各区大小比例,默认开启
- 分析
- 默认开启,将会导致Eden区和Survivor区的比例自动分配,因此也会引起我们默认值-XX:SurvivorRatio=8失效,所以真实比例可能不是8,比如可能是6等
- 如何设置Eden区和Survivor区的比例:-XX:SurvivorRatio=8
- 显示使用显示使用Eden区和Survivor区的比例,那就使用我自己的
- 没有显示使用Eden区和Survivor区的比例,无论打开或者关闭-XX:+UseAdaptiveSizePolicy,都会自动设置Eden区和Survivor区的比例
- 结论:
- 只有显示使用Eden区和Survivor区的比例,才会让比例生效,否则比例都会自动设置,最后推荐使用默认打开的-XX:+UseAdaptiveSizePolicy设置,并且不显示设置-XX:SurvivorRatio
-XX:NewRatio=2
- 设置老年代与年轻代(包括1个Eden区和2个Survivor区)的比值,默认为2
- 根据实际情况进行设置,主要根据对象生命周期来进行分配,如果对象生命周期很长,那么让老年代大一点,否则让新生代大一点
-XX:PretenureSizeThreadshold=1024
- 设置让大于此阈值的对象直接分配在老年代,单位为字节
- 只对Serial、ParNew收集器有效
- 不好控制
-XX:MaxTenuringThreshold=15
- 默认值为15
- 新生代每次MinorGC后,还存活的对象年龄+1,当对象的年龄大于设置的这个值时就进入老年代
-XX:+PrintTenuringDistribution
- 让JVM在每次MinorGC后打印出当前使用的Survivor中对象的年龄分布
-XX:TargetSurvivorRatio
- 表示MinorGC结束后Survivor区域中占用空间的期望比例
3、方法区
- 永久代
-XX:PermSize=256m
- 设置永久代初始值为256M
-XX:MaxPermSize=256m
- 设置永久代最大值为256M
- 元空间
-XX:MetaspaceSize
- 初始空间大小
-XX:MaxMetaspaceSize
- 最大空间,默认没有限制
-XX:+UseCompressedOops
- 使用压缩对象指针
-XX:+UseCompressedClassPointers
- 使用压缩类指针
-XX:CompressedClassSpaceSize
- 设置Klass Metaspace的大小,默认1G
4、直接内存
-XX:MaxDirectMemorySize
- 指定DirectMemory容量,若未指定,则默认与Java堆最大值一样
3、OutOfMemory相关的选项
-XX:+HeapDumpOnOutMemoryError
- 表示在内存出现OOM的时候,生成Heap转储文件,以便后续分析
- -
XX:+HeapDumpBeforeFullGC
和-XX:+HeapDumpOnOutMemoryError
只能设置1个
-XX:+HeapDumpBeforeFullGC
- 表示在出现FullGC之前,生成Heap转储文件,以便后续分析
-XX:+HeapDumpBeforeFullGC
和-XX:+HeapDumpOnOutMemoryError
只能设置1个- 请注意FullGC可能出现多次,那么dump文件也会生成多个,而OOM只能有一次,所以
-XX:+HeapDumpOnOutMemoryError
生成的dump文件只有一个
-XX:HeapDumpPath=<path>
- 指定heap转存文件的存储路径,如果不指定,就会将dump文件放在当前目录中
-XX:OnOutOfMemoryError
指定一个可行性程序或者脚本的路径,当发生OOM的时候,去执行这个脚本
对OnOutOfMemoryError的运维处理:
以部署在linux系统/opt/Server目录下的Server.jar为例:
在run. sh启动脚本中添加jvm参数:
-XX:OnOutOfMemoryError=/opt/Server/restart.sh
restart.sh脚本:
linux环境:
1
2
3
4!/bin/bash
pid=$(ps -eflgrep Server.jar|awk '{if($8=="java") {print $2}}')
kill -9 $pid
cd /opt/Server/;sh run.shWindows环境:
1
2
3
4echo off
wmic process where Name='java.exe' delete
cd D:\Server
start run.bat
4、垃圾收集器相关选项
7款经典收集器与垃圾分代之间的关系:
垃圾收集器的组合关系:
两个收集器间有连线,表明它们可以搭配使用:
Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
其中Serial Old作为CMS出现”Concurrent Mode Failure” 失败的后备预案。
(红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214) ,即:移除。
(绿色虚线)JDK 14中:弃用Parallel Scavenge和Serial Old GC组合(JEP366)
(青色虚线)JDK 14中:删除CMS垃圾回收器 (JEP 363)
1、查看默认的垃圾回收器
-XX:+PrintCommandLineFlags
:查看命令行相关参数(包含使用的垃圾收集器)- 使用命令行指令:
jinfo - flag 相关垃圾回收器参数进程ID
以上两种方式都可以查看默认使用的垃圾回收器,第一种方式更加准备,但是需要程序的支持;第二种方式需要去尝试,如果使用了,返回的值中有+
号(使用),否则就是-
号(没使用)。
2、Serial回收器
- Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器。
- Serial Old是运行在Client模式下默认的老年代的垃圾回收器。
-XX:+UseSerialGC
:- 指定年轻代和老年代都使用串行收集器。等价于新生代用Serial GC,且老年代用Serial Old GC。 可以获得最高的单线程收集效率。
3、Parnew回收器
-XX: +UseParNewGC
:- 手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。
-XX:ParallelGCThreads
:- 设置年轻代并行收集器的线程数。一般地, 最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。
- 在默认情况下,当CPU数量小于8个, ParallelGCThreads 的值等于CPU数量。
- 当CPU数量大于8个,ParallelGCThreads的值等于
3 + [5 * CPU_Count] / 8]
。
注意:根据下图可知,该回收器最终将会没有搭档,那就相当于被遗弃了(JDK14以后)
4、Parallel回收器
-XX:+UseParallelGC
- 手动指定年轻代使用Parallel并行收集器执行内存回收任务。
-XX: +UseParallelOldGC
:- 手动指定 =老年代都是使用并行回收收集器。
- 分别适用于新生代和老年代。默认jdk8是开启的。
- 上面两个参数,默认开启一个, 另一个也会被开启。(互相激活)
- 手动指定 =老年代都是使用并行回收收集器。
-XX:ParallelGCThreads
:- 设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。
- 在默认情况下,当CPU 数量小于8个,ParallelGCThreads 的值等于CPU数量。
- 当CPU数量大于8个,ParallelGCThreads的值等于
3 + [5 * CPU_Count] / 8]
。
-XX:MaxGCPauseMillis
:- 设置垃圾收集器最大停顿时间(即STW的时间)。单位是毫秒。
- 为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数。
- 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。
- 该参数使用需谨慎。
-XX:GCTimeRatio
:- 垃圾收集时间占总时间的比例(= 1 / (N + 1))。用于衡量吞吐量的大小。
- 取值范围(0,100) 。默认值99,也就是垃圾回收时间不超过1%。
- 与前一个
-XX:MaxGCPauseMillis
参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例。
-XX: +UseAdaptiveSizePolicy
:- 设置Parallel Scavenge收集器具有自适应调节策略
- 在这种模式下,年轻代的大小、Eden 和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
- 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio) 和停顿时间(MaxGCPauseMills) ,让虚拟机自已完成调优工作。
注意:
- Parallel回收器主打吞吐量,而CMS和G1主打低延迟,如果主打吞吐量,那么就不应该限制最大停顿时间,所以-XX:MaxGCPauseMills不应该设置
- -XX:MaxGCPauseMills中的调整堆大小通过默认开启的-XX:+UseAdaptiveSizePolicy来实现
- -XX:GCTimeRatio用来衡量吞吐量,并且和-XX:MaxGCPauseMills矛盾,因此不会同时使用
5、CMS回收器
-XX:+UseConcMarkSweepGC
:- 手动指定使用CMS收集器执行内存回收任务。
- 开启该参数后会自动将
-XX:+UseParNewGC
打开。 - 即:ParNew(Young区用)+CMS(Old区用)+Serial Old的组合。
-XX:CMS1nitiatingOccupanyFraction
:- 设置堆内存使用率的阅值,一旦达到该阈值,便开始进行回收。
- JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收。**JDK6及以上版本默认值为92%**。
- 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阅值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Full GC的执行次数。
-XX:+UseCMSCompactAtFullCollection
:- 用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时
间变得更长了。
- 用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时
-XX:CMSFullGCsBeforeCompaction
:- 设置在执行多少次Full GC后对内存空间进行压缩整理。
-XX:ParallelCMSThreads
:- 设置CMS的线程数量。
- CMS默认启动的线程数是**(ParallelGCThreads + 3) / 4**,ParallelGCThreads 是年轻代并行收集器的线程数。当CPU 资源比较紧涨时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。
-XX:ParallelCMSThreads和ParallelGCThreads有关系,ParallelGCThreads在上面Parnew回收器中有提到
另外,CMS收集器还有如下常用参数:
-XX:ConcGCThreads
:- 设置并发垃圾收集的线程数,默认该值是基于ParallelGCThreads计算出来的;
-XX:+UseCMSInitiatingOccupancyOnly
:- 是否动态可调,用这个参数可以使CMS直按CMSInitiatingOccupancyFraction设定的值启动
-XX:+CMSScavengeBeforeRemark
:- 强制hotspot 虚拟机在cms remark阶段之前做一次minor gc,用于提高remark阶段的速度
-XX:+CMSClassUnloadingEnable
:- 如果有的话,启用回收Perm区(JDK8之前)
-XX:+CMSParallelInitialEnabled
:- 用于开启CMS initial-mark阶段采用多线程的方式进行标记,用于提高标记速度,在Java8开始已经默认开启;
-XX:+CMSParallelRemarkEnbled
:- 用户开启CMS remark阶段采用多线程的方式进行重新标记。默认开启;
-XX:+ExplicitGCInvokesConcurrent
、-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses
:- 这两个参数用户指定hotspot虚拟在执行System.gc()时使用CMS周期;
-XX:+CMSPrecleaningEnabled
:- 指定CMS是否需要进行Pre cleaning这个阶段
特别说明:
- JDK9新特性:CMS被标记为Deprecate 了(JEP291)
- 如果对JDK 9及以上版本的HotSpot虚拟机使用参数
-XX:+UseConcMarkSweepGC
来开启CMS收集器的话,用户会收到一个警告信息,提示
CMS未来将会被废弃。
- 如果对JDK 9及以上版本的HotSpot虚拟机使用参数
- JDK14新特性:删除CMS垃圾回收器(JEP363)
- 移除了CMS垃圾收集器,如果在JDK14中使用
-XX:+UseConcMarkSweepGC
的话JVM不会报错,只是给出一个warning信息,但是不会exit。JVM会自动回退以默认GC方式启动JVM - OpenJDK 64-Bit Server VM warning: Ignoring option UseConcMarkSweepGc;support was removed in 14.0 and the VM will continue execution using the default collector.
- 移除了CMS垃圾收集器,如果在JDK14中使用
6、G1回收器
-XX:+UseG1GC
:- 手动指定使用G1收集器执行内存回收任务。
-XX:G1HeapRegionSize
:- 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。
-XX: MaxGCPauseMillis
:- 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms
-XX: ParallelGCThread
:- 设置STW时GC线程数的值。最多设置为8
-XX:ConcGCThreads
:- 设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。
-XX:InitiatingHeapoccupancyPercent
:- 设置触发并发GC周期的Java堆占用率阙值。超过此值,就触发GC。默认值是45。
-XX: G1NewSizePercent
、-XX:G1MaxNewSizePercent
:- 新生代占用整个堆内存的最小百分比(默认5%)、最大百分比( 默认60%)
-XX:G1ReservePercent=10
- 保留内存区域,防止to space (Survivor中的to区)溢出
注意:
- 如果使用G1垃圾收集器,不建议设置
-Xmn
和-XX:NewRatio
,毕竟可能影响G1的自动调节
Mixed GC调优参数:
- 注意:
- G1收集器主要涉及到Mixed GC,Mixed GC 会回收young区和部分old区。
- G1关于MixedGC调优常用参数:
-XX:InitiatingHeapOccupancyPercent
:- 设置堆占用率的百分比(0到100)达到这个数值的时候触发global concurrent marking (全局并发标记),默认为45%**。值为0表示间断进行全局并发标记**。
-XX:G1MixedGCLiveThresholdPercent
:- 设置Old区的region被回收时候的对象占比,**默认占用率为85%**。只有Old区的region中存活的对象占用达到了这个百分比才会在Mixed GC中被回收。
-XX:G1HeapWastePercent
:- 在global concurrent marking (全局并发标记)结束之后,可以知道所有的区有多少空间要被回收,在每次young GC之后和再次发生Mixed GC之前,会检查垃圾占比是否达到此参数,只有达到了,下次才会发生Mixed GC。
-XX:G1MixedGCCountTarget
:- 一次global concurrent marking (全局并发标记)之后,最多执行Mixed GC的次数,默认是8。
-XX:G1OldCSetRegionThresholdPercent
:- 设置Mixed GC收集周期中要收集的Old region数的 上限。默认值是Java堆的10%
7、怎么选择垃圾收集器
- 优先调整堆的大小让JVM自适应完成。
- 如果内存小于100M,使用串行收集器
- 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
- 如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
- 如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器。官方推荐G1,性能高。现在互联网的项目,基本都是使用G1。
特别说明:
- 没有最好的收集器,更没有万能的收集;
- 调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器
5、GC日志相关选项
1、常用参数
-verbose:gc
:输出日志信息,默认输出的标准输出
可以独立使用
-XX:+PrintGC
:等同于
-verbose:gc
表示打开简化的日志可以独立使用
**
-XX:+PrintGCDetails
**:在发生垃圾回收时打印内存回收详细的日志,并在进程退出时输出当前内存各区域的分配情况
可以独立使用
**
-XX:+PrintGCTimeStamps
**:程序启动到GC发生的时间秒数
不可以独立使用,需要配合-XX:+PrintGCDetails使用
-XX:+PrintGCDateStamps
:输出GC发生时的时间戳(以日期的形式,例如:2013-05-04T21:53:59.234+0800)
不可以独立使用,可以配合-XX:+PrintGCDetails使用
-XX:+PrintHeapAtGC
:每一次GC前和GC后,都打印堆信息
可以独立使用
若配合**
-XX:+PrintGCDetails
**一起使用的话,可以将两个命令的结果结合在一起:每一次GC前和GC后,都打印堆信息+在进程退出时输出当前内存各区域的分配情况
**
-XIoggc:<file>
**:把GC日志写入到一个文件中去,而不是打印到标准输出中
2、其他参数
-XX:TraceClassLoading
:- 监控类的加载
-XX:PrintGCApplicationStoppedTime
:打印GC时线程的停顿时间
-XX:+PrintGCApplicationConcurrentTime
:- 垃圾收集之前打印出应用未中断的执行时间
-XX:+PrintReferenceGC
:- 记录回收了多少种不同引用类型的引用
-XX:+PrintTenuringDistribution
:- 让JVM在每次MinorGC后打印出当前使用的Survivor中对象的年龄分布
-XX:+UseGCLogFileRotation
:- 启用GC日志文件的自动转储
-XX:NumberOfGCLogFiles=1
:- GC日志文件的循环数目
-XX:GCLogFileSize=1M
:- 控制GC日志文件的大小
6、其他参数
-XX:+DisableExplicitGC
:- 禁用hotspot执行System.gc(),默认禁用
-XX:ReservedCodeCacheSize=<n>[g|m|k]
、-XX:InitialCodeCacheSize=<n>[g|m|k]
:- 指定代码缓存的大小
-XX:+UseCodeCacheFlushing
:- 使用该参数让jvm放弃一些被编译的代码,避免代码缓存被占满时JVM切换到interpreted-only的情况
-XX:+DoEscapeAnalysis
:- 开启逃逸分析
-XX:+UseBiasedLocking
:- 开启偏向锁
-XX:+UseLargePages
:- 开启使用大页面
-XX:+PrintTLAB
:- 打印TLAB的使用情况
-XX:TLABSize
:- 设置TLAB大小
4、通过Java代码获取JVM参数
Java提供了java.lang.management包用于监视和管理Java虚拟机和Java运行时中的其他组件,它允许本地和远程监控和管理运行的Java虛拟机。其中ManagementFactory这个类还是挺常用的。另外还有Runtime类也可以获取一些内存、CPU核数等相关的数据。
通过这些api可以监控我们的应用服务器的堆内存使用情况,设置一些阈值进行报警等处理。
代码:
1 | /** |
在上篇可以通过Runtime获取:
代码:
1 | public class HeapSpaceInitial { |
5、分析GC日志
1、GC日志参数
同上面第4、JVM运行时参数中的第5点、GC日志相关选项一致
2、GC日志格式
1、复习:GC分类
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)
- 部分收集(Partial GC):不是完整收集整个 Java 堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC / Young GC):只是新生代(Eden / S0, S1)的垃圾收集
- 老年代收集(Major GC / Old GC):只是老年代的垃圾收集。
- 目前,只有 CMS GC 会有单独收集老年代的行为。
- 注意:
- 在进行Major GC之前,系统会先进行一次Minor GC
- 很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收。
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
- 目前,只有 G1 GC 会有这种行为
- 整堆收集(Full GC):收集整个 java 堆和方法区的垃圾收集。
那些情况会触发Full GC呢?
- 老年代空间不足
- 方法区空间不足
- 显式调用System.gc()
- Minor GC进入老年代的数据的平均大小 大于 老年代的可用内存
- 大对象直接进入老年代,而老年代的可用空间不足
2、GC日志分类
1、Minor GC
MinorGC(或 young GC 或 YGC)日志:
1 | [GC (Allocation Failure) [PSYoungGen: 31744K->2192K (36864K) ] 31744K->2200K (121856K), 0.0139308 secs] [Times: user=0.05 sys=0.01, real=0.01 secs] |
2、Full GC
1 | [Full GC (Metadata GC Threshold) [PSYoungGen: 5104K->0K (132096K) ] [Par01dGen: 416K->5453K (50176K) ]5520K->5453K (182272K), [Metaspace: 20637K->20637K (1067008K) ], 0.0245883 secs] [Times: user=0.06 sys=0.00, real=0.02 secs] |
3、GC日志结构剖析
1、透过日志看垃圾收集器
- 使用
Serial收集器
在新生代的名字是Default New Generation,因此显示的是”[DefNew
“ - 使用
ParNew收集器
在新生代的名字会变成”[ParNew
“,意思是”Parallel New Generation’ - 使用
Parallel Scavenge收集器
在新生代的名字是”[PSYoungGen
“,这里的JDK1.7使用的就是PSYoungGen - 使用
Parallel Old Generation收集器
在老年代的名字是”[ParOldGen
“ - 使用
G1收集器
的话,会显示为”garbage-first heap
“
2、透过日志看 GC 原因
Allocation Failure
:表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了Metadata GCThreshold
:Metaspace 区不够用了FErgonomics
:JVM 自适应调整导致的 GCSystem
:调用了 System.gc()方法
3、透过日志看 GC 前后情况
通过图示,我们可以发现 GC 日志格式的规律一般都是:GC 前内存占用 -> GC 后内存占用(该区域内存总大小)
1 | [PSYoungGen: 5986K->696K (8704K) ] 5986K->704K (9216K) |
- 中括号内:GC 回收前年轻代堆大小,回收后大小,(年轻代堆总大小)
- 括号外:GC 回收前年轻代和老年代大小,回收后大小,(年轻代和老年代总大小)
注意:
- Minor GC 堆内存总容量 = 9/10 年轻代 + 老年代。
- 原因是 Survivor 区只计算 from 部分,而 JVM 默认年轻代中 Eden 区和 Survivor 区的比例关系,Eden:S0:S1=8:1:1。
4、透过日志看 GC 时间
GC 日志中有三个时间:user,sys 和 real(结果采用四舍五入的形式)
user
:进程执行用户态代码(核心之外)所使用的时间。这是执行此进程所使用的实际 CPU 时间,其他进程和此进程阻塞的时间并不包括在内。在垃圾收集的情况下,表示 GC 线程执行所使用的 CPU 总时间。sys
:进程在内核态消耗的 CPU 时间,即在内核执行系统调用或等待系统事件所使用的 CPU 时间real
:程序从开始到结束所用的时钟时间。这个时间包括其他进程使用的时间片和进程阻塞的时间(比如等待 I/O 完成)。对于并行 gc,这个数字应该接近(用户时间+系统时间)除以垃圾收集器使用的线程数。
由于多核的原因,一般的 GC 事件中,real time 是小于 sys time + user time 的,因为一般是多个线程并发的去做 GC,所以 real time 是要小于 sys + user time 的。如果 real > sys + user 的话,则你的应用可能存在下列问题:IO 负载非常重或 CPU 不够用。
4、Minor GC日志解析
-XX:+PrintGCTimeStamps
+ -XX:+PrintGCDateStamps
+ -XX:+PrintGCDetails
:
1 | 2020-11-20T17:19:43.265-0800:0.822: [GC (ALLOCATION FAILURE) [PSYOUNGGEN:76800K->8433K(89600K)] 76800K -> 8449K(294400K), 0.0088371 SECS] [TIMES:USER=0.02 SYS=0.01, REAL=0.01 SECS] |
- 2020-11-20T17:19:43.265-0800
- 日志打印时间日期格式如:2013-05-04T21:53:59.234+0800
- 0.822
- gc发生时,Java虛拟机启动以来经过的秒数
- [GC (Allocation Failure)
- 发生了一次垃圾回收,这是一次Minor GC。它不区分新生代GC还是老年代GC,括号里的内容是gc发生的原因,这里的Allocation Failure的原因是新生代中没有足够区域能够存放需要分配的数据而失败。
- 除此之外还有:
Metadata GCThreshold
:Metaspace 区不够用了FErgonomics
:JVM 自适应调整导致的 GCSystem
:调用了 System.gc()方法
- [PSYoungGen:76800K->8433K(89600K)]
- PSYoungGen:表示GC发生的区域,区域名称与使用的GC收集器是密切相关的
- Serial收集器:Default New Generation显示DefNew
- ParNew收集器:ParNew
- Parallel Scanvenge收集器:PSYoungGen
- 老年代和新生代同理,也是和收集器名称相关
- 76800K->8433K(89600K):GC前该内存区域已使用容量 -> GC后该区域容量(该区域总容量)
- 如果是新生代,总容量则会显示整个新生代内存的9/10,即eden + from区(默认SurvivorRatio = 8)
- 如果是老年代,总容量则是全部内存大小,无变化
- PSYoungGen:表示GC发生的区域,区域名称与使用的GC收集器是密切相关的
- 76800K -> 8449K(294400K)
- 在显示完区域容量GC的情况之后,会接着显示整个堆内存区域的GC情况:GC前堆内存已使用容量 -> GC堆内存容量(堆内存总容量)
- 堆内存总容量 = 9/10新生代+老年代 < 初始化的内存大小.
- 0.0088371 secs]
- 整个GC所花费的时间,单位是秒
- [Times:user=0.02 sys=0.01, real=0.01 secs]
- user:指的是CPU工作在用户态所花费的时间
- sys:指的是CPU工作在内核态所花费的时间
- real:指的是在此次GC事件中所花费的总时间
5、Full GC日志解析
-XX:+PrintGCTimeStamps
+ -XX:+PrintGCDateStamps
+ -XX:+PrintGCDetails
:
1 | 2020-11-20T17:19:43.794-0800:1.351:[FULL GC (METADATA GC THRESHOLD)[PSYOUNGGEN:10082K -> 0K(89600K)][PAROLDGEN:32K -> 9638K(204800K)] |
- 2020-11-20T17:19:43.794-0800
- 日志打印时间日期格式如:2013-05-04T21:53:59.234+0800
- 1.351
- gc发生时,Java虛拟机启动以来经过的秒数
- [Full GC (Metadata GC Threshold)
- 发生了一次垃圾回收,这是一次FULL GC。它不区分新生代GC还是老年代GC
- 括号里的内容是gc发生的原因,这里的MetadataGC Threshold的原因是Metaspace区不够用了。
- Full GC (Ergonomics):JVM自适应调整导致的GC
- Full GC (System):调用了System.gc()方法
- [PSYoungGen:10082K -> 0K(89600K)]
- PSYoungGen:表示GC发生的区域,区域名称与使用的GC收集器是密切相关的
- Serial收集器:Default New Generation显示DefNew
- ParNew收集器:ParNew
- Parallel Scanvenge收集器:PSYoungGen
- 老年代和新生代同理,也是和收集器名称相关
- 10082K -> 0K(89600K):GC前该内存区域已使用容量 -> GC后该区域容量(该区域总容量)
- 如果是新生代,总容量则会显示整个新生代内存的9/10,即eden + from区(默认SurvivorRatio = 8)
- 如果是老年代,总容量则是全部内存大小,无变化
- PSYoungGen:表示GC发生的区域,区域名称与使用的GC收集器是密切相关的
- [ParOldGen:32K -> 9638K(204800K)]
- 老年代区域没有发生GC,因为本次GC是metaspace引起的
- 10114K -> 9638K(294400K)
- 在显示完区域容量GC的情况之后,会接着显示整个堆内存区域的GC情况:GC前堆内存已使用容量 -> GC堆内存容量(堆内存总容量)
- 堆内存总容量 = 9/10新生代+老年代 < 初始化的内存大小.
- [Metaspace:20158K -> 20156K(1067008K)]
- metaspace GC回收2K空间
- 0.0285388 secs]
- 整个GC所花费的时间,单位是秒
- [Times:user=0.11 sys=0.00, real=0.03 secs]
- user:指的是CPU工作在用户态所花费的时间
- sys:指的是CPU工作在内核态所花费的时间
- real:指的是在此次GC事件中所花费的总时间
6、G1 GC的日志分析
3、GC日志分析工具
1、GCEasy
- 基本概述:
- GCEasy 是一款在线的 GC 日志分析器,可以通过 GC 日志分析进行内存泄露检测、GC 暂停原因分析、JVM 配置建议优化等功能,大多数功能是免费的。(当然有一些服务还是收费的)
- 下载安装:官网地址
选择需要分析的log文件 -> 点击Analyze -> 可以点击Download将分析结果下载下来进行离线分析
相关分析:
案例1:MetaspaceOOM
案例2:老年代满了导致堆OOM
2、GCViewer
- 基本概述
- GCViewer是一个免费的、开源的分析小工具,用于可视化查看由SUN/Oracle、IBM、HP和BEA Java虚拟机产生的垃圾收集器的日志。
- GCViewer 是一款离线的 GC 日志分析器,用于可视化 Java VM 选项 -verbose:gc 和 .NET 生成的数据 -Xloggc:
。还可以计算与垃圾回收相关的性能指标(吞吐量、累积的暂停、最长的暂停等)。 - 当通过更改世代大小或设置初始堆大小来调整特定应用程序的垃圾回收时,此功能非常有用。
- 下载安装:
3、其他工具
- GChisto
- 基本概述:GChisto是一款专业分析gc日志的工具,可以通过gc日志来分析:MinorGC、Full GC的次数、频率、持续时间等,通过列表、报表、图表等不同形式来反应gc的情况。
- 虽然界面略显粗糙,但是功能还是不错的。
- 官网上没有下载的地方,需要自己从 SVN 上拉下来编译
- 不过这个工具似乎没怎么维护了,存在不少 bug
- HPjmeter
- 工具很强大,但是只能打开由以下参数生成的 GC log,-verbose:gc -Xloggc:gc.log。添加其他参数生成的 gc.log 无法打开
- HPjmeter 集成了以前的 HPjtune 功能,可以分析在 HP 机器上产生的垃圾回收日志文件
6、OOM常见各种场景及解决方案
1、案例1:堆溢出
2、案例2:元空间溢出
3、案例3:GC overhead limit exceeded
4、案例4:线程溢出
7、性能优化案例
1、性能测试工具:Jmeter
2、案例1:调整堆大小提高服务的吞吐量
3、案例2:调整垃圾回收器提高服务的吞吐量
4、案例3:JVM优化之JIT优化
5、案例4:G1并发执行的线程数对性能的影响
6、案例5:合理配置堆内存
7、特殊问题:新生代与老年代的比例
1、参数设置
2、参数AdaptiveSizePolicy
- 补充
8、案例6:CPU占用很高排查方案
9、日均百万级订单交易系统如何设置JVM参数
1、现状
2、解决思路
3、参数配置
8、Java代码层及其它层面调优
9、大厂面试题
- 支付宝:
- 支付宝三面:JVM性能调优都做了什么?
- 小米:
- 有做过JVM内存优化吗?
- 从SQL、JVM、 架构、数据库四个方面讲讲优化思路
- 蚂蚁金服:
- JVM的编译优化
- JVM性能调优都做了什么
- JVM诊断调优工具用过哪些?
- 二面:jvm怎样调优,堆内存、栈空间设置多少合适
- 三面:JVM相关的分析工具使用过的有哪些?具体的性能调优步骤如何
- 阿里:
- 如何进行JVM调优?有哪些方法?
- 如何理解内存泄漏问题?有哪些情况会导致内存泄漏?如何解决?
- 字节跳动:
- 三面: JVM如何调优、参数怎么调?
- 拼多多:
- 从SQL、JVM、架构、数据库四个方面讲讲优化思路
- 京东:
- JVM诊断调优工具用过哪些?
- 每秒几十万并发的秒杀系统为什么会频繁发生GC?
- 日均百万级交易系统如何优化JVM?
- 线上生产系统00M如何监控及定位与解决?
- 高并发系统如何基于G1垃圾回收器优化性能?
参考资料:
相关网站: