JVM精讲与GC调优
字节码篇
字节码指令
//包装类对象的缓存问题
@Test
public void test5(){
Integer i1 = 10;
Integer i2 = 10;
system.out.print1n(i1 ==i2); //true
Integer i3 = 128;
Integer i4 = 128;
system.out.print1n(i3 -=i4);//false
Boolean b1 = true;
Boolean b2 = true;
system.out.print1n(b1 == b2);//true
)
包装类对象的缓存问题
包装类 | 缓存对象(范围内的数是从缓存中获取,否则就是new的新对象) |
---|---|
Byte | -128-127 |
Short | -128-127 |
Integer | -128-127 |
Long | -128-127 |
Float | 没有 |
Double | 没有 |
Character | 0-127 |
Boolean | true和false |
// String声明的字面量数据都放在字符串常量池中
// jdk 6中字符串常量池存放在方法区(即永久代中)
// jdk7及以后字符串常量池存放在堆空间
@Test
public void test6(){
// new的String放在堆中,str存放堆中的地址
string str = new String("hello") + new string("world");
// 加上intern()后,输出结果变为true;如果intern()在str声明之后,则输出结果依然为false
str.intern();
// str1指向常量池中的地址
string str1 = "helloworld";
system.out.println(str == str1);//false --> true ( 加上intern(),且在str声明之前 )
}
* @return a string that has the same contents as this string, but is
* guaranteed to be from a pool of unique strings.
* 返回常量池中的引用地址
*/
public native String intern();
class Father {
int x = 10;
public Father() {
// 哪个对象调用,this就代表哪个对象
this.print();
x = 20;
}
public void print() {
system.out.println( "Father.x = ” +x);
}
}
c1ass Son extends Father {
int x = 30;
public son() {
// 哪个对象调用,this就代表哪个对象
this.print();
x = 40;
}
public void print() {
system.out.print1n( "Son.x = ” +x);
}
}
public class BytecodeInterview1 {
public static void main(String[ ] args) {
Father f = new Son();
// 输出结果为:
// Son.x = 0
// Son.x = 30
// 20
system.out.print1n(f.x);
}
}
Class文件结构细节
class文件有几个部分
class文件结构概述
class文件的结构并不是一成不变的,随着Java虚拟机的不断发展,总是不可避免地会对Class文件结构做出一些调整,但是其基本结构和框架是非常稳定的。
class文件的总体结构如下:
-
魔数:class文件的标志
-
class文件版本
-
常量池:可以理解为Class文件之中的资源仓库,它是class文件结构中与其他项目关联最多的数据类型(后面的很多数据类型都会指向此处),也是占用Class文件空间最大的数据项目之一。
-
字面量:文本字符串,声明为final的常量值
-
符号引用:类和接口的全限定名,字段的名称和描述符,方法的名称和描述符
-
符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
-
直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。
-
-
访问标识(或标志)
-
类索引,父类索引,接口索引集合
-
字段表集合
-
方法表集合
-
属性表集合
字节码指令
-
加载与存储指令
-
算术指令
-
类型转换指令
-
对象的创建与访问指令
-
方法调用与返回指令
-
操作数栈管理指令
-
控制转移指令
-
异常处理指令
-
同步控制指令
public class InterviewTest {
@Test
public void test1(){
Integer x = 128;
int y = 128;
// 自动拆箱,结果为true
system.put.print1n(x == y);
}
}
Java虚拟机中,数据类型可以分为哪几类?
- Java虚拟机是通过某些数据类型来执行计算的,数据类型可以分为两种,基本类型和引用类型,基本类型的变量持有原始值,而引用类型的变量持有引用值。
-
Java语言中的所有基本类型同样也都是Java虚拟机中的基本类型。但是boolean有点特别,虽然 Java虚拟机也把boolean看做基本类型,但是指令集对boolean只有很有限的支持,当编译器把Java源代码编译为字节码时,它会用int或者byte来表示boolean。在Java虚拟机中,false是由整数零来表示的,所有非零整数都表示true,涉及boolean值的操作则会使用int。另外,boolean数组是当做byte数组来访问的。
-
Java虚拟机还有一个只在内部使用的基本类型:returnAddress,Java程序员不能使用这个类型,这个基本类型被用来实现Java程序中的finally子句。该类型是jsr ,ret以及jsr_w指令需要使用到的,它的值是JVM指令的操作码的指针。returnAddress类型不是简单意义上的数值,不属于任何一种基本类型,并且它的值是不能被运行中的程序所修改的。
-
Java虚拟机的引用类型被统称为“引用(reference)”,有三种引用类型。类类型、接口类型、以及数组类型,它们的值都是对动态创建对象的引用。类类型的值是对类实例的引用。数组类型的值是对数组对象的引用,在Java虚拟机中,数组是个真正的对象。而接口类型的值,则是对实现了该接口的某个类实例的引用。还有一种特殊的引用值是null,它表示该引用变量没有引用任何对象。
为什么不把基本数据类型放在堆中
首先是栈、堆的特点不同。(堆比栈要大,但是栈比堆的运算速度要快)
将复杂数据类型放在堆中的目的是为了不影响栈的效率,而是通过引用的方式去堆中查找。(八大基本类型的数据大小创建时候已经确立,三大引用类型创建时候无法确定大小)
简单数据类型比较稳定,并且它只占据很小的内存,将它放在空间小、运算速度快的栈中,能够提高效率。
类的加载篇
类的加载过程
同一个类只会被同一个类的加载器加载一次,加载过程如下:
类的装载(loading)
所谓装载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型——类模板对象。
类模型的位置:加载的类在JVM中创建相应的类结构,类结构会存储在方法区(JDK1.8之前:永久代;JDK1.8及之后元空间)。
装载完成的操作
装载阶段,简言之,查找并加载类的二进制数据,生成Class的实例。在加载类时,Java虚拟机必须完成以下3件事情:
- 通过类的全名,获取类的二进制数据流
- 解析类的二进制数据流为方法区内的数据结构(Java类模型)
- 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口
Linking
- 验证
它的目的是保证加载的字节码是合法、合理并符合规范的。
- 准备
简言之,为类的静态变量分配内存,并将其初始化为默认值。
在这个阶段,虚拟机就会为这个类分配相应的内存空间,并设置默认初始值。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。
注意:
1.这里不包含基本数据类型的字段用static final修饰的情况,因为final在编译的时候就会分配了,准备阶段会显式赋值(但不完全是,有些情况也是在初始化阶段才会显示赋值,详情见“初始化”阶段讲解)。
2.注意这里不会为实例变量分配初始化,实例变量是会随着对象一起分配到Java堆中。
3.在这个阶段并不会像初始化阶段中那样会有初始化或者代码被执行。
- 解析
简言之,将类、接口、字段和方法的符号引用转为直接引用。
通过解析操作,符号引用就可以转变为目标方法在类中方法表中的位置,从而使得方法被成功调用。
所谓解析就是将符号引用转为直接引用,也就是得到类、字段、方法在内存中的指针或者偏移量。因此,可以说,如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段。但只存在符号引用,不能确定系统中一定存在该结构。
不过]ava虚拟机规范并没有明确要求解析阶段一定要按照顺序执行。在HotSpot VM中,加载、验证、准备和初始化会按照顺序有条不紊地执行,但链接阶段中的解析操作往往会伴随着JVM在执行完初始化之后再执行。
初始化
初始化阶段,简言之,为类的静态变量赋予正确的初始值。到了初始化阶段,才真正开始执行类中定义的 Java程序代码。
初始化阶段的重要工作是执行类的初始化方法:<clinit>()方法。
- 该方法仅能由Java编译器生成并由JVM调用,程序开发者无法自定义一个同名的方法,更无法直接在Java程序中调用该方法,虽然该方法也是由字节码指令所组成。
- 它是由类静态成员的赋值语句以及static语句块合并产生的。
注意:<clinit>():只有在给类的中的static的变量显式赋值或在静态代码块中赋值了,才会生成此方法。
<init>():一定会出现在Class的method表中。
/**使用static + final修饰的成员变量,称为:全局常量。
*什么时候在链接阶段的准备环节显示赋值:给此全局常量附的值是字面量或常量,不涉及到方法或构造器的调用。
*除此之外,都是在初始化环节赋值的。
*/
public class InitializationTest2 {
public static int a = 1; //在初始化阶段赋值
public static final int INT_CONSTANT = 10;//在链接阶段的准备环节赋值
public static Integer INTEGER_CONSTANT1 = Integer.valueof(100);//在初始化阶段赋值
public static final Integer INTEGER_CONSTANT2 = Integer.valueof(1000); //在初始化阶段赋值
public static final string so = "helloworldo";//在链接阶段的准备环节赋值
public static final string s1 = new String("helloworld1");//在初始化阶段赋值
public static string s2 = "hellowor1d2";//在初始化阶段赋值
public static final int NUN1 = new Random().nextInt(10);//在初始化阶段赋值
static int a = 9;//在初始化阶段赋值
static final int b = a; //在初始化阶段赋值
}
- <clinit>()的调用会死锁吗?
对于<clinit>()方法的调用,也就是类的初始化,虚拟机会在内部确保其多线程环境中的安全性。
虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。
正是因为函数<clinit>()带锁线程安全的,因此,如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,引发死锁。并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息。
如果之前的线程成功加载了类,则等在队列中的线程就没有机会再执行<clinit>()方法了。那么,当需要使用这个类时,虚拟机会直接返回给它已经准备好的信息。
- 类的初始化情况
①主动使用的情况
Java虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里指的“使用”,是指主动使用。
主动使用只有下列几种情况:(即:如果出现如下的情况,则会对类进行初始化操作。而初始化操作之前的加载、验证、准备已经完成)
- 当创建一个类的实例时,比如使用new关键字,或者通过反射、克隆、反序列化。
- 当调用类的静态方法时,即当使用了字节码invokestatic指令。
- 当使用类、接口的静态字段时(final修饰特殊考虑),比如,使用getstatic或者putstatic指令。
- 当使用java. lang.reflect包中的方法反射类的方法时。比如:Class.forName( “com. atguigu.java.Test”)
- 当初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当初次调用 MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。(涉及解析REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄对应的类)
②被动使用的情况
并不是在代码中出现的类,就一定会被加载或者初始化,如果不符合主动使用的条件,类就不会初始化。
- 当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。如:当通过子类引用父类的静态变量,不会导致子类初始化
- 通过数组定义类引用,不会触发此类的初始化
- 引用常量不会触发此类或接口的初始化,因为常量在链接阶段就已经被显式赋值了。
- 调用ClassLoader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化。
被动的使用,意味着不需要执行初始化环节,意味着没有<clinit>()的调用。
public class T {
public static int k = 0;
public static T t1 = new T( str: "t1");
public static T t2 = new T( str: "t2");
public static int i = print("i");
public static int n = 99;
public int j = print( "j");
{
print("构造块");
}
static {
print("静态块");
}
public T(string str) {
system.out.println((++k)+":" + str + "i=" + i+ " n=" +n);
++n ;
++i;
}
public static int print(string str) {
system.out.println((++k)+":" + str + " i="+ i + " n=" +n);
++n;
return ++i;
}
public static void main(string[] args) {
}
}
// 运行程序main方法,输出结果:
1:j i=0 n=0
2:构造块 i=1 n=1
3:t1 i=2 n=2
4:j i=3 n=3
5:构造块 i=4 n=4
6:t2 i=5 n=5
7:i i=6 n=6
8:静态块 i=7 n=99
类的加载器
ClassLoader在整个装载(loading)阶段,只能影响到类的加载,而无法通过ClassLoader去改变类的链接和初始化行为。至于它是否可以运行,则由Execution Engine决定。
类的加载分类:显式加载 vs 隐式加载
class文件的显式加载与隐式加载的方式是指JVM加载class文件到内存的方式。
- 显式加载:指的是在代码中通过调用ClassLoader加载class对象,如直接使用Class.forName(name)或this.getClass().getclassLoader().loadclass()加载class对象。
- 隐式加载:则是不直接在代码中调用ClassLoader的方法加载class对象,而是通过虚拟机自动加载到内存中,如在加载某个类的class文件时,该类的class文件中引用了另外一个类的对象,此时额外引用的类将通过JVM自动加载到内存中。
通常类加载机制有三个基本特征:
- 双亲委派模型。但不是所有类加载都遵守这个模型,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如JDK内部的ServiceProvider/ServiceLoader机制,用户可以在标准API框架上,提供自己的实现,JDK也需要提供些默认的参考实现。例如,Java 中JNDI、JDBC、文件系统、Cipher等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。
- 可见性。子类加载器可以访问父加载器加载的类型,但是反过来是不允许的。不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
- 单一性。由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见。
类的加载器分类与测试
- 除了顶层的启动类加载器外,其余的类加载器都应当有自己的“父类”加载器。
- 不同类加载器看似是继承(Inheritance)关系,实际上是包含关系。在下层加载器中,包含着上层加载器的引用。
启动类加载器(引导类加载器,Bootstrap classLoader)
- 这个类加载使用C/C++语言实现的,嵌套在JVM内部。
- 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar或sun.boot.class.path路径下的内容)。用于提供JVM自身需要的类。
- 并不继承自java.lang.classLoader,没有父加载器。
- 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
扩展类加载器(Extension ClassLoader)
- Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
- 继承于ClassLoader类
- 父类加载器为启动类加载器
- 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载
应用程序类加载器(系统类加载器,AppClassLoader)
- java语言编写,由sun.misc.Launcher$AppClassLoader实现
- 继承于ClassLoader类
- 父类加载器为扩展类加载器
- 它负责加载环境变量classpath或系统属性java.class.path 指定路径下的类库
- 应用程序中的类加载器默认是系统类加载器
- 它是用户自定义类加载器的默认父加载器
- 通过ClassLoader的getSystemclassLoader()方法可以获取到该类加载器
用户自定义类加载器
- 在ava的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的。在必要时,我们还可以自定义类加载器,来定制类的加载方式。
- 体现Java语言强大生命力和巨大魅力的关键因素之一便是,Java开发者可以自定义类加载器来实现类库的动态加载,加载源可以是本地的JAR包,也可以是网络上的远程资源。
- 通过类加载器可以实现非常绝妙的插件机制,这方面的实际应用案例举不胜举。例如,著名的OSGI组件框架,再如Eclipse的插件机制。类加载器为应用程序提供了一种动态增加新功能的机制,这种机制无须重新打包发布应用程序就能实现。
- 同时,自定义加载器能够实现应用隔离,例如 Tomcat,Spring等中间件和组件框架都在内部实现了自定义的加载器,并通过自定义加载器隔离不同的组件模块。这种机制比C/C++程序要好太多,想不修改C/C++程序就能为其新增功能,几乎是不可能的,仅仅一个兼容性便能阻挡住所有美好的设想。
- 所有用户自定义类加载器通常需要继承于抽象类java.lang.ClassLoader。
ClassLoader源码剖析
自定义类的加载器
为什么要自定义类加载器?
- 隔离加载类
在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境。比如:阿里内某容器框架通过自定义类加载器确保应用中依赖的jar包不会影响到中间件运行时使用的jar包。再比如:Tomcat这类web应用服务器,内部自定义了好几种类加载器,用于隔离同一个web应用服务器上的不同应用程序。(类的仲裁–>类冲突) - 修改类加载的方式
类的加载模型并非强制,除Bootstrap外,其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载 - 扩展加载源
比如从数据库、网络、甚至是电视机机顶盒进行加载 - 防止源码泄漏
Java代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码。
写一个自定义类加载器
public class UserDefineClassLoader extends ClassLoader {
private String rootPath;
public UserDefineClassLoader(String rootPath) {
this.rootPath = rootPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 转换为以文件路径表示的文件
String filePath = classToFilePath(name);
byte[] data = getBytesFromPath(filePath);
// 自定义CLassLoader内部调用defineClass()
return defineClass(name,data, 0 ,data.length);
}
private byte[] getBytesFromPath(String filePath) {
FileInputStream fis = null;
ByteArrayOutputStream baos = null;
try {
fis = new FileInputStream(filePath);
baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = fis.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (null != baos)
baos.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
if (null != fis)
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}
private String classToFilePath(String name) {
return rootPath + "\\" + name.replace(".", "\\") + ".class";
}
public static void main(String[] args) {//加载com.cui.Test
try {
UserDefineClassLoader loader1 = new UserDefineClassLoader("D:\\self\\code\\javaBase\\target\\classes");
Class<?> loader1Class = loader1.findClass("com.cui.Test");
System.out.println(loader1Class);
UserDefineClassLoader loader2 = new UserDefineClassLoader("D:\\self\\code\\javaBase\\target\\classes");
Class<?> loader2Class = loader2.findClass("com.cui.Test");
System.out.println(loader1Class == loader2Class);
System.out.println(loader1Class.getClassLoader());
System.out.println(loader1Class.getClassLoader().getParent());
} catch (Exception e) {
}
}
}
// 输出结果
class com.cui.Test
false
com.cui.jvm.UserDefineClassLoader@1b6d3586
sun.misc.Launcher$AppClassLoader@14dad5dc
双亲委派机制
双亲委派机制优势:
- 避免类的重复加载,确保一个类的全局唯一性
Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关系可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。 - 保护程序安全,防止核心API被随意篡改
双亲委托模式的弊端:
检查类是否加载的委托过程是单向的,这个方式虽然从结构上说比较清晰,使各个ClassLoader的职责非常明确,但是同时会带来一个问题,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类。
通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题,但是系统类访问应用类就会出现问题。比如在系统类中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。
双亲委派机制的打破实例
-
第一次破坏双亲委派机制:
双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前―—即JDK 1.2面世以前的“远古”时代。
由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK1.2之后的java.lang.classLoader中添加一个新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadclass()中编写代码。上节我们已经分析过loadclass()方法,双亲委派的具体逻辑就实现在这里面,按照1oadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。 -
第二次破坏双亲委派机制:线程上下文类加载器
双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的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()方法进行设置,如果创建线程时还未设置,它将从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
- 第三次破坏双亲委派机制:
双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的。如:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。
IBM公司主导的SR-291(即OSGi R4.2)实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构。
当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:
1)将以java.*开头的类,委派给父类加载器加载。
2)否则,将委派列表名单内的类,委派给父类加载器加载。
3)否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
4)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给FragmentBundle的类加载器加载。
6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
7)否则,类查找失败。
Tomcat类加载机制
Tomcat8 和Tomcat6比较大的区别是:
Tomcat8可以通过配置表示遵循双亲委派机制
加载过程:
当应用需要到某个类时,则会按照下面的顺序进行类加载
1、使用bootstrap引导类加载器加载
2、使用system系统类加载器加载
3、使用应用类加载器在WEB-INF/classes中加载
4、使用应用类加载器在WEB-INF/lib中加载
5、使用common类加载器在CATALINA_HOME/Iib中加载
JDK9中加载的新变化
运行时内存篇
程序计数器
为了保证程序(在操作系统中理解为进程)能够连续地执行下去,CPU必须具有某些手段来确定下一条指令的地址。而程序计数器正是起到这种作用,所以通常又称为指令计数器。
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。执行引擎的字书码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
总结:
- 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。不会随着程序的运行需要更大的空间。
- 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
- 它是唯一一个在Java虚拟机规范中没有规定任何OutOtMemoryError情况的区域
虚拟机栈
Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
- 如果采用固定大小的ava虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常。
- 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常。
如何设置栈内存的大小?
-Xss size(即:-XX:ThreadStackSize)
- 一般默认为512k-1024k,取决于操作系统。
- 栈的大小直接决定了函数调用的最大可达深度。
栈帧
- 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
- Java方法有两种返回函数的方式,一种是正常的函数返回,作用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
栈帧内部结构
- 局部变量表
- 局部变量表也被称之为局部变量数组或本地变量表
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型(8种)、对象引用(reference),以及returnAddress类型。|
- 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
- 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
- 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
- 操作数栈
- 局部变量压栈指令将给定的局部变量表中的数据压入操作数栈。
这类指令大体可以分为:
xload_<n>(x为i、l、f、d、a,n为0到3)
xload (x为i、l、f、d、a)
说明:在这里,x的取值表示数据类型。
- 常量入栈
常量入栈指令的功能是将常数压入操作数栈,根据数据类型和入栈内容的不同,又可以分为const系列、push系列和ldc指令。
指令const系列:用于对特定的常量入栈,入栈的常量隐含在指令本身里。
指令push系列:主要包括bipush和sipush。它们的区别在于接收数据类型的不同,bipush接收8位整数作为参数,sipush接收16位整数,它们都将参数压入栈。
指令ldc系列:如果以上指令都不能满足需求,那么可以使用万能的ldc指令,它可以接收一个8位的参数,该参数指向常量池中的int、float或者String的索引,将指定的内容压入堆栈。
出栈装入局部变量表指令:用于将操作数栈中栈顶元素弹出后,装入局部变量表的指定位置,用于给局部变量赋值。这类指令主要以store的形式存在。
- 动态链接
动态链接(或指向运行时常量池的方法引用):
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如: invokedynamic指令
- 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
- 方法返回地址
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
问题总结
问题一:栈溢出的情况?
栈溢出:StackOverflowError
举个简单的例子:在main方法中调用main方法,就会不断压栈执行,直到栈溢出;栈的大小可以是固定大小的,也可以是动态变化(动态扩展)的。
如果是固定的,可以通过-Xss设置栈的大小;
如果是动态变化的,当栈大小到达了整个内存空间不足了,就是抛出outOfMemory异常(java. lang.outOfMemoryError)
问题二:调整栈大小,就能保证不出现溢出吗?
不能。因为调整栈大小,只会减少出现溢出的可能,栈大小不是可以无限扩大的,所以不能保证不出现溢出
问题三:分配的栈内存越大越好吗?
不是。因为增加栈大小,会造成每个钱程的栈都变的很大,使得一定的栈空间下,能创建的线程数量会变小
问题四:垃圾回收是否会涉及到虚拟机栈?
不会。垃圾回收只会涉及到方法区和堆中,方法区和堆也会存在溢出的可能;程序计数器,只记录运行下一行的地址,不存在溢出和垃圾回收;虚拟机栈和本地方法栈,都是只涉及压栈和出栈,可能存在栈溢出,不存在垃圾回收。
问题五:方法中定义的局部变量是否线程安全?
变量有逃逸,就不安全;否则,就安全。
本地方法接口与本地方法栈
堆
核心概述
所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread LocalAllocation Buffer,TLAB)。
什么是TLAB?
- 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
- 所有OpenJDK衍生出来的JVM都提供了TLAB的设计。
堆的内部结构
如何设置堆内存大小
- Java堆区用于存储Java对象实例,那么堆的大小在VM启动时就已经设定好了,大家可以通过选项”-Xmx”和”-Xms”来进行设置。
“-Xms”用于表示堆区的起始内存,等价于-XX:InitialHeapSize;“-Xmx”则用于表示堆区的最大内存,等价于-XX:MaxHeapSize - 一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutOfMemoryError异常。
- 通常会将 -Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
注意:
- heap默认最大值计算方式:如果物理内存少于192M,那么heap最大值为物理内存的一半。如果物理内存大于等于1G,那么heap的最大值为物理内存的1/4。
- heap默认最小值计算方式:最少不得少于8M,如果物理内存大于等于1G,那么默认值为物理内存的1/64,即1024/64=16M。最小堆内存在jvm启动的时候就会被初始化。
设置新生代与老年代比例
配置新生代与老年代在堆结构的占比。
- 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
- 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
- 可以使用选项”-Xmn”设置新生代最大内存大小,这个参数一般使用默认值就可以了。
设置Eden和survivor比例:
- 在HotSpot中,Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1
- 当然开发人员可以通过选项“-XX:SurvivorRatio”调整这个空间比例。比如-XX : SurvivorRatio=8
分配空间担保策略
如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。
JDK6 update 24之后,默认开启。
对象分配金句
-
针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to
-
关于垃圾回收:
- 频繁在新生区收集
- 很少在养老区收集
- 几乎不在永久区/元空间收集
啥时候能去养老区呢?可以设置次数。默认是15次。
可以设置参数:-XX:MaxTenuringThreshold=<N>设置对象晋升老年代的年龄阀值
针对不同年龄段的对象分配原则如下所示:
-
优先分配到Eden
-
大对象直接分配到老年代
尽量避免程序中出现过多的大对象
-
长期存活的对象分配到老年代
-
动态对象年龄判断
如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
-
空间分配担保
-XX:HandlePromotionFailure
解释MinorGC、MajorGC、FullGC
针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC);一种是整堆收集(Full GC)。
- 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC / Young GC):只是新生代(Eden\so,s1)的垃圾收集
- 老年代收集(Major GC / old GC):只是老年代的垃圾收集。
①目前,只有CMS GC会有单独收集老年代的行为。
②注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。 - 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
目前,只有G1 GC会有这种行为
- 整堆收集(Full GC):收集整个iava堆和方法区的垃圾收集。
Full GC触发机制
触发Full GC执行的情况有如下五种:
(1)调用System.gc()时,系统建议执行Full GC,但是不必然执行
(2)老年代空间不足
(3)方法区空间不足
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
(5)由Eden区、survivor space0 (From Space)区向survivor space1 (To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
说明: full gc是开发或调优中尽量要避免的,这样暂时时间会短一些。
OOM如何解决
堆空间分代思想
经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。
- 新生代:有Eden、两块大小相同的Survivor(又称为from/to,s0/s1)构成,to总为空。
- 老年代:存放新生代中经历多次GC仍然存活的对象。
其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC 的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
快速分配策略(TLAB)
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
在程序中,开发人员可以通过选项“-XX:+/-UseTLAB”设置是否开启TLAB空间。
默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
方法区
在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。
元空间的本质和永久代类似,都是对VM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。
测试设置方法区大小参数的默认值
jdk7及以前:
-XX:PermSize=100m -XX:MaxPermSize=100m。默认值:-XX:PermSize是20.75M,-XX:MaxPermSize在32位机器默认是64M,64位机器模式是82M。抛出内存溢出错误:java.lang.outOfMemoryError: PermGen space
jdk8及以后:
-XX:MetaspaceSize=100m -XX :MaxMetaspaceSize=100m。默认值:windows下,-XX:MetaspaceSize是21M,- XX:MaxMetaspaceSize 的值是-1,即没有限制。抛出内存溢出错误:java. lang.outOfMemoryError: Metaspace
方法区出现OOM异常的原因:
- 加载大量的第三方的jar包;
- Tomcat部署的工程过多(30-50个);
- 大量动态的生成反射类
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
直接内存
StringTable
stringTable为什么要调整?
jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。
这就导致StringTaule回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
—20231115—
对象内存布局篇
对象的实例化
创建对象的几种方式
- new
- 最常见的方式
- 变形1:Xxx的静态方法
- 变形2:XxxBuilder/xxxFactory的静态方法
- Class的newlnstance():反射的方式,只能调用空参的构造器,权限必须是public
- Constructor的newInstance(Xxx):反射的方式,可以调用空参、带参的构造器,权限没有要求,实用性更好
- 使用clone():不调用任何构造器,当前类需要实现Cloneable接口,实现clone(),默认浅拷贝
- 使用反序列化:从文件中、数据库中、网络中获取一个对象的二进制流,反序列化为内存中的对象
- 第三方库Objenesis,利用了asm字节码技术,动态生成Constructor对象团
创建对象的步骤
-
判断对象对应的类是否加载、链接、初始化
-
为对象分配内存
指针碰撞
空闲列表 -
处理并发安全问题
在分配内存空间时,需要保证new对象时候的线程安全性:创建对象是非常频繁的操作,虚拟机需要解决并发问题。虚拟机采用了两种方式解决并发问题:
- CAS (Compare And Swap )失败重试、区域加锁:保证指针更新操作的原子性
- TLAB把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区,(TLAB , Thread Local Allocation Buffer)虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定
-
初始化分配到的空间
-
设置对象的对象头
-
执行init方法进行初始化
对象的内存布局
-
对象头(Header)
对象头:它主要包括两部分:
- 一个是对象自身的运行时元数据(mark word)。
- 哈希值(hashcode):对象在堆空间中都有一个首地址值,栈空间的引用根据这个地址指向堆中的对象,这就是哈希值起的作用
- GC分代年龄:对象首先是在Eden中创建的,在经过多次GC后,如果没有被进行回收,就会在survivor中来回移动,其对应的年龄计数器会发生变化,达到阙值后会进入养老区
- 锁状态标志,在同步中判断该对象是否是锁
- 线程持有的锁
- 线程偏向ID
- 偏向时间戳
- 另一个是类型指针,指向元数据区的类元数据InstanceKlass,确定该对象所属的类型
- 一个是对象自身的运行时元数据(mark word)。
-
实例数据(Instance Data)
作用:它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段〈包括从父类继承下来的和本身拥有的字段)。
这里需要遵循的一些规则:
- 相同宽度的字段总是被分配在一起
- 父类中定义的变量会出现在子类之前(因为父类的加载是优先于子类加载的)
- 如果CompactFields参数为true(默认为true):子类的窄变量可能插入到父类变量的空隙
-
对齐填充(Padding)
对象的访问定位
方式1:句柄访问
实现:堆需要划分出一块内存来做句柄池,reference中存储对象的句柄池地址,句柄中包含对象实例与类型数据各自具体的地址信息。
好处: reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很遍)时只会改变句柄中实例数据指针,reference本身不需要被修改。
方式2:直接使用指针访问
实现:reference中存储的就是对象的地址,如果只是访问对象本身的话,就不而要多一次间接访问的开销。
HotSpot使用直接指针访问的方式。