[No0000187]可能是把Java内存区域讲的最清楚的一篇文章
写在前面(常见面试题)
基本问题:
- 介绍下 Java 内存区域(运行时数据区)
- Java 对象的创建过程(五步,建议能默写出来并且要知道每一步虚拟机做了什么)
- 对象的访问定位的两种方式(句柄和直接指针两种方式)
拓展问题:
- String类和常量池
- 8种基本类型的包装类和常量池
Java程序员必看文档
哈哈 皮一下!我自己开源的一个Java学习指南文档。一份涵盖大部分Java程序员所需要掌握的核心知识,正在一步一步慢慢完善,期待您的参与。Github地址:https://github.com/Snailclimb/Java-Guide 。看一眼,我觉得你一定不会后悔,如果可以的话,可以给个Star鼓励一下!
1 概述
对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像C/C++程序开发程序员这样为内一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。
2 运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
这些组成部分一些事线程私有的,其他的则是线程共享的。
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存
2.1 程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完。
另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
2.2 Java 虚拟机栈
与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型。
Java 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。
局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
2.3 本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
2.4 堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:在细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
2.5 方法区
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
HotSpot 虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出问题。
相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。
2.6 运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)
既然运行时常量池时方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
JDK1.7及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。同时在 jdk 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域
推荐阅读:《Java中几种常量池的区分》: https://blog.csdn.net/qq_26222859/article/details/73135660
2.7 直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError异常出现。
JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
3 HotSpot 虚拟机对象探秘
通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。
3.1 对象的创建
下图便是 Java 对象的创建过程,我建议最好是能默写出来,并且要掌握每一步在做什么。
①类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
②分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式:(补充内容,需要掌握)
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的
内存分配并发问题(补充内容,需要掌握)
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB: 为每一个线程预先在Eden区分配一块儿内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配
③初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
④设置对象头: 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
⑤执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init>
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
3.2 对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为3快区域:对象头、实例数据和对齐填充。
Hotspot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的自身运行时数据(哈希吗、GC分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
3.3 对象的访问定位
建立对象就是为了使用对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄和②直接指针两种:
- 句柄: 如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
- 直接指针: 如果使用直接指针访问,那么 Java 堆对像的布局中就必须考虑如何防止访问类型数据的相关信息,reference 中存储的直接就是对象的地址。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
四 重点补充内容
String 类和常量池
1 String 对象的两种创建方式:
String str1 = "abcd";
String str2 = new String("abcd");
System.out.println(str1==str2);//false
这两种不同的创建方法是有差别的,第一种方式是在常量池中拿对象,第二种方式是直接在堆内存空间创建一个新的对象。
记住:只要使用new方法,便需要创建新的对象。
2 String 类型的常量池比较特殊。它的主要使用方法有两种:
- 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
- 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用。
String s1 = new String("计算机");
String s2 = s1.intern();
String s3 = "计算机";
System.out.println(s2);//计算机
System.out.println(s1 == s2);//false,因为一个是堆内存中的String对象一个是常量池中的String对象,
System.out.println(s3 == s2);//true,因为两个都是常量池中的String对
3 String 字符串拼接
String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing";//常量池中的对象
String str4 = str1 + str2; //在堆上创建的新的对象
String str5 = "string";//常量池中的对象
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false
尽量避免多个字符串拼接,因为这样会重新创建对象。如果需要改变字符串的花,可以使用 StringBuilder 或者 StringBuffer。
String s1 = new String("abc");这句话创建了几个对象?
创建了两个对象。
验证:
String s1 = new String("abc");// 堆内存的地值值
String s2 = "abc";
System.out.println(s1 == s2);// 输出false,因为一个是堆内存,一个是常量池的内存,故两者是不同的。
System.out.println(s1.equals(s2));// 输出true
结果:
false
true
解释:
先有字符串"abc"放入常量池,然后 new 了一份字符串"abc"放入Java堆(字符串常量"abc"在编译期就已经确定放入常量池,而 Java 堆上的"abc"是在运行期初始化阶段才确定),然后 Java 栈的 str1 指向Java堆上的"abc"。
8种基本类型的包装类和常量池
- Java 基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean;这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。
- 两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。
Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出true
Integer i11 = 333;
Integer i22 = 333;
System.out.println(i11 == i22);// 输出false
Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出false
Integer 缓存源代码:
/**
*此方法将始终缓存-128到127(包括端点)范围内的值,并可以缓存此范围之外的其他值。
*/
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
应用场景: 1. Integer i1=40;Java 在编译的时候会直接将代码封装成Integer i1=Integer.valueOf(40);,从而使用常量池中的对象。 2. Integer i1 = new Integer(40);这种情况下会创建新的对象。
Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);//输出false
Integer比较更丰富的一个例子:
Integer i1 = 40;
Integer i2 = 40;
Integer i3 = 0;
Integer i4 = new Integer(40);
Integer i5 = new Integer(40);
Integer i6 = new Integer(0);
System.out.println("i1=i2 " + (i1 == i2));
System.out.println("i1=i2+i3 " + (i1 == i2 + i3));
System.out.println("i1=i4 " + (i1 == i4));
System.out.println("i4=i5 " + (i4 == i5));
System.out.println("i4=i5+i6 " + (i4 == i5 + i6));
System.out.println("40=i5+i6 " + (40 == i5 + i6));
结果:
i1=i2 true
i1=i2+i3 true
i1=i4 false
i4=i5 false
i4=i5+i6 true
40=i5+i6 true
解释:
语句i4 == i5 + i6,因为+这个操作符不适用于Integer对象,首先i5和i6进行自动拆箱操作,进行数值相加,即i4 == 40。然后Integer对象无法与数值进行直接比较,所以i4自动拆箱转为int值40,最终这条语句转为40 == 40进行数值比较。
写在最后
开源文档推荐
Java-Guide:一份涵盖大部分Java程序员所需要掌握的核心知识,正在一步一步慢慢完善,期待您的参与。
Github地址:https://github.com/Snailclimb/Java-Guide
参考:
- 《深入理解Java虚拟机:JVM高级特性与最佳实践(第二版》
- 《实战java虚拟机》
- https://www.cnblogs.com/CZDblog/p/5589379.html
- https://www.cnblogs.com/java-zhao/p/5180492.html
- https://blog.csdn.net/qq_26222859/article/details/73135660
- https://blog.csdn.net/cugwuhan2014/article/details/78038254
写个 Java 程序让 Metaspace 溢出
Maven 依赖
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>3.1</version>
</dependency>
虚拟机参数
-XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M -verbose:class
Java 程序
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
public class Permanent {
public static void main(String[] args) {
int i = 0;
while (true) {
System.out.println(i++);
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Permanent.class);
enhancer.setUseCache(false);
enhancer.setCallback(
new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(o, args);
}
}
);
enhancer.create();
}
}
}
Java虚拟机解析_胖子程序员的博客-CSDN博客
[jvm解析系列][一]Java内存区域分配和内存溢出异常OOM
学过操作系统的同学应该比较清楚,一个操作系统必须要有完善的内存管理系统(页/段式的管理),相应的jvm全称java虚拟机应该也有类似的一种管理内存的方式,这种方式是建立在真实的操作系统内存管理方式之上的,他把内存分配成了不同的区域,形成了java内存模型。
那么,对于其他博客讲解这种题目要先抛一个图解出来,我并不想这样。因为这种模型的出现肯定是要解决问题的,我们需要顺延着前人设计jvm内存模型的脚步,看看到底为什么要这样设计,才能更好的理解它。
首先,我们思考一下,java需要什么区域呢?
1、必不可少的,一个程序计数器,用来记录当前程序运行的字节码指令,jvm使用了栈而不是寄存器结构(往后看相信你会理解的)。对于线程来讲,每个线程肯定是需要记录自己的执行位置,所以程序计数器是线程私有的
2、既然有了程序计数器我们还需要一个区域来存放当前运行的程序,那就是Java虚拟机栈,每一个方法的执行就会添加一个栈帧在虚拟机栈里(如果想要详细的了解栈帧请关注博客,随着系列的加深,我会一个个讲解)。对于线程来讲虚拟机栈同样的是线程私有。
3、一样的常用jvm的都知道还有一种jni的调用,为了这种方法的运行,设计了一种跟java虚拟机栈的双胞胎区域叫本地方法栈,除了运行native方法外其他的几乎一致。(在有的虚拟机里不区分native和java)
4、大家都知道java是面向对象的,既然方法都出来了,那么类存储在那呢?没错,jvm也分化出来了这样的一个区域叫做方法区(也称为永久代,因为这个区域很长时间不会发生gc)。这个区域里存储了虚拟机家在的所有的类信息,常量和静态变量等。(内含一个运行时常量池主要用于存放编译器生成的字面量和符号引用)他是线程公有的。
5、如果类都有了,那么对象呢?没错这就是jvm的重头戏,很多工作都在这个地方完成,他就是传说中的java堆,在有的地方甚至把jvm笼统的分为堆栈,可见java堆的重要性。java堆里面存放了几乎所有的对象实例,在java虚拟机规范中这样说:所有的对象实例和数组都要在堆上分配。随着JIT编译器等的发展也不那么绝对了。(java堆仍然很复杂,后期单开一篇博客详解)。它肯定是线程公有的。
6、到这里,好像java所有的需要都被分配了,以前确实是的,但是在JDK1.4之后,java引入了nio技术(不在本章范围内讲解),这就要求需要分配一个新的区域,没错,他叫直接内存。值得一提的是,这一区域并不是jvm的规范中的一部分,也不是虚拟机运行时数据区的一部分,但是他会引起OOM。
那么至此所有的内存区域分配完毕,配图如下帮助理解,相信你已经差不多的了解和理解了jvm分配的方式和目的了。
内存区域分配配图
上节说到~jvm的内存分配,所以对应的应该有6种溢出方式,实际上程序计数器一般不会溢出,我又把两个方法栈合在一起讲,所以溢出的可能性有4种
嘿嘿嘿嘿,我要去考试了,等我回来再补吧~
回来了继续补
1、Java堆溢出
java堆溢出一般都是因为在for或者递归里调用了太多次new对象的操作,并且可以被GCROOT查找到不能GC,而导致java堆内存不足报错,报错一般如下:
OutOfMemoryError:Java heap spce
2、虚拟机栈和本地方法栈溢出
虚拟机栈和本地方法溢出一般也是陷入了无限循环里并且在无限循环里一直在调用一个方法,因为一般情况下栈深能有1000~2000,报错一般都是StackOverflowError,OOM反而不常见,在处理这种异常时很棘手,需要减少其他区域的内存,因为这块内存是用总内存减去堆和方法区得到的。报错如下:
StackOverflowError.
3、方法区和运行时常量池溢出
这个区域想要溢出单单使用创建类的方法和常量是困难的,所以可以使用String.intern()方法直接将字符串加入到常量池。报错一般如下
OOM:PermGen space
4、本机直接内存溢出
由于上文知道直接内存一般用于NIO操作,所以使用了native方法而且因为这个区域的特殊性Heap dump并没有明显的报错。错误信息如下:
OutOfMemory。
[jvm解析系列][二]Java堆的详细讲解和对象的分配过程和访问
上回说到Jvm内存的分配,犹如划地分治,把一块本机内存分裂成了6块。
这回我们就讲讲java堆的详细信息
java堆里也不是铁板一块,类似jvm的分治,java堆内部也好不到哪里去,大致上可以分成新生代和老年代,他们内部也不和谐,新生代又可以细分为Eden和两个survivor空间。
(按照复制算法画图并解析,其他算法请见第四章)图片如下:
是不是看着大小比例很别扭呢,没办法呀,就是按照内存大小画的-。-iii
至于他们的作用的,请关注博客,下回分解。那么回到原题,我们分解了java堆的内部情况,接下来是不是就要讲对象的分配过程了?
yeah,对象怎么分配的呢?(默认以hotspot 虚拟机为例)
在hotspot中把对象分成了对象头,实例数据和对齐填充。
在对象头中包括了两部分分别是:
1、存储对象自身的运行时数据:hashcode,GC分代年龄信息(后面会详细说明),锁标志等等。
2、类型指针:它指向它的类,没错就是关在方法区的那家伙。(个别例外没有,如数组)
在实例数据中才是干货:
定义的各种类型的字段内容(包括父类继承)。
还有一部分是对齐补充
因为内存管理的系统要求对象起始地址必须是8字节的整数倍,如果对象不能正好8字节整数倍结束只能凑了。
好了,对象的问题解决了,那么我们分配了总不能不用吧,那么我们该怎么找到我们的对象呢?
想想我们我们什么时候喜欢使用对象呢?在方法中调用!像这样XXX.xxx();对吗?
还记得方法被关在哪了吗?不记得请回去看看第一篇,谢谢。
好,你不看我就告诉你吧,方法在调用的时候被转化成栈帧,放在了方法栈里。在方法栈里调用的对象是被存储成reference数据的,这个reference可以看成一个指针,其实它也就是个指针,在JVM规范中它被规定成了指向对象的引用。我们拿到这个reference的内容就能找到对象的内存地址了。
不对!现在男女比例这个鸟样,程序猿想找对象哪有那么简单?
这就要看你是什么虚拟机了?你要是高富帅虚拟机还是简单的,你找对象估计就是这个过程:
但是屌丝虚拟机怎么办?屌丝找对象都说是个女的就行。。一般也找不着。。最后还不是都是靠媒婆(句柄)
当然它们各有各的好处,不然也不会同时存在。
高富帅虚拟机他比较省时间,直接找对象哪,不要媒婆在中间当然会节省时间,但是这种自己谈的对象感情深啊,每次对象出差都要揪心(对象在堆中GC时会被移动,每次移动都需要修改reference的数据)
屌丝虚拟机找对象是慢了点,但是大家感情都不深,对象出去出个差也不是那么揪心,只要在句柄那修改一下就好了。
[jvm解析系列][三]Java的垃圾回收(一)如何鉴别垃圾,四种引用类型
垃圾回收就想垃圾车,每次天亮就会沿着街区开一圈,把垃圾都带走。
有的区域不需要这种垃圾车,也许它们不造垃圾吧,作为一个中级图钉我对这种地区很无奈,但是它们就是很少有垃圾甚至没有垃圾。首先应该是程序计数器,这玩意要什么垃圾回收,我都不用讲。下一个应该是虚拟机栈本地方法栈,栈帧的进出都受到控制,谁的垃圾谁带走,在进门的时候就算清楚了你要用多少内存,最后你再全部带走一点垃圾也不剩也不需要。(还有一个原因,是上述三个区域都是线程私有的,当线程撤退的时候把垃圾也都带走了)
那么根据第一章讲的,我们还剩几个区域?不记得就回去看吧^_^
还剩方法区(又称永久代)和java堆。也就是说只剩这两个区需要垃圾回收了,垃圾佬表示很兴奋啊,终于有垃圾收了。(虽然叫永久代,但是现在永久代也是回收垃圾的,只是垃圾的回收率低,并且条件十分苛刻)
那么什么是垃圾呢?垃圾如何定义呢?
嗯,在我们的概念中,不要的东西就是垃圾。在程序中如何确定一个对象我们不用了呢?,引用计数法,也就是每当该对象被引用时+1,被去引用时-1,到引用为0的时候也就是该对象变垃圾的时候。嘿嘿嘿,我又瞎说了,没错是有这么个用法,但是主流jvm一个都没有使用这种方法定义垃圾的!
为什么呢?这个方法不是挺好的么?
我举个例子,如果我有
A a = new A();
B b = new B();
a.b = b;
b.a = a;
这样的情况下是不相互调用了?如果看不懂没关系我画幅图给你
你蒙蔽了吗,是不是a和b对象直接就这样泄漏了,我的妈,你的android如果用这种方式定义垃圾你是不是半天就得重启一回防止累计下来的大量的内存泄漏呢?
所以我们的jvm没有选择引用计数算法,我们采用了可达性分析算法
在大多数商用的有垃圾回收的语言中多数都是采用这个算法的。简单的说来就是从某一个GC Roots开始往下找引用,没找到的就是垃圾。具体请看图:
图一帖上来打架都应该清楚了。1和2是垃圾,要回收。这个就是可达性分析算法。那么1说了:“凭什么root可以作为GC Roots”而我不行!“2跟着附和:”这中间肯定有不可告人的“某图钉:”py交易“。
其实并不是这样,GC roots是有要求的,我们来分析一下,我们说一个对象不是垃圾,肯定我们是能得到的,我们在写程序的时候什么类型是可以在任何地方都能得到的呢?
1、虚拟机栈中引用的对象(即我在写方法的时候A a =new A(),在方法没结束的时候就是a就是可以作为root使用的)
2、方法区中类静态属性(所以啊,我们应该少用类的静态属性,不然内存回收不了)
3、方法去中常量引用的对象
4、本地方法栈中JNI引用的对象
当一个类型是上述四个中的一个的时候,能跟他攀上关系的对象都不会被回收。
但是!后来改革了你知道吗?(四种引用强度)原本的时候攀上一个大佬就高枕无忧了,但是在jdk1.2之后这些对象引起了重视被改革了。
号外号外!一号文件规定以后引用不再是boolean类型了,不再是有或者没有的问题了~
它变成了四类引用
1、强引用
强引用是大家最常见的引用,他是jvm必不可少的,当强引用过多时宁愿oom也不回收内存,最常见的应该就是new出来的对象了。
2、软引用
软引用如果jvm不缺内存的话不会回收,一旦缺少内存就会回收软引用而不是报oom。SoftReference一般都是软引用
3、弱引用
弱引用更不用说了,只要被jvm扫描到了立即回收。WeakReference一般都是软引用
4、虚引用
虚引用。。。这玩意一不小心就没了,至于他为什么和弱引用分开是因为他有特殊的机制,在jvm回收内存的时候如果这个对象是虚引用,必须把这个虚引用放在引用队列里,如果引用队列里有一个虚引用说明这个对象要挂了。
[jvm解析系列][四]Java的垃圾回收(二)垃圾收集算法,内存分配和回收策略
上回说到如何鉴别一个垃圾。
这回咱们讲讲怎么收集垃圾收集垃圾有几种算法如下:
1、标记-清除算法
这个算法最为基础,我们先讲算法再说优缺点。
实现过程:
标记出所有需要回收的对象,当标记完成后统一回收。图解如下:
优缺点:
可能画出来图的时候大家都发现了,这个算法有一个很明显的问题,那就是大量的不连续的内存碎片,这样的内存碎片遇到大对象分配的时候很可能遇到内存不足的 情况,当然出了这个情况以外还有一种问题就效率太低(可以对比之后的算法)。
2、复制算法
实现:把内存划分为大小相等的两块,当GC的时候,就把其中一块复制到另一块上,然后直接清理掉原本的那一半内存。
优缺点:
这种算法要比标记清除算法效率高并且没有内存碎片,但是这样会浪费一半内存而且如果存货较多对象,复制效率也很低。
修正与应用:
现在商业虚拟机大多都采用这种方式回收新生代,但是不回划一半内存那么大。它们把内存氛围Eden空间(80%)和两块Survivor空间(10%+10%),每次GC时, 从Eden和一个survivor区里复制活着的对象到另一个survivor里,因为新生代 GC频繁且效率高,所以一半清除后的对象一个survivor基本可以存下,但是如果空间不够 用,就会引起老年代的分配担保(在本章稍后讲解)。
3、标记-整理算法
实现:
让所有存活的对象向前端移动,最后清除后面的内存
优缺点:
效率高于标记-清除算法,而且可以保证所有的内存都可以使用
应用:
这种算法可以有效的用在老年代中,因为老年代gc不频繁而且每次效率不高。
我们了解到了怎么清理内存之后,这在之前还有一个问题,分配对象的规则是什么呢?
1、分配对象
我们用流程图来讲解:
1、我们可以看出来一个对象在分配的时候先要看看Eden区是否可以装得下(或者设置了PretenureSizeThreshold参数,根据参数来判断)
2、如果装不下就直接分配到老年区
3、如果分配的下又会查询Eden空间是否是充足
4、如果Eden剩余可用内存充足就把对象放在了Eden区域
5、如果Eden不充足的话就会引起MinorGC(新生代GC,新生代GC效率较高大概是FullGC(老年代GC)的十倍),如果发现Eden中的对象不足以放在survivor中就 会直接放在老年代里,然后分配对象到新生代。
2、长期存活对象转入老年代
图解:
特殊情况:
虚拟机中并没有严格的遵守必须年龄>=MaxTenuringThreshold才能转到老年代,如果Survivor空间中相同年龄所有对象大小的综合大雨Survivor空间的一半,年 龄>=它们的也可直接转到老年代。
3、空间分配担保
实现:
在MinorGC之前每次都会查看老年代的最大连续可用空间是不是大于新生代所有总对象总空间,如果大于的话说明新生代可以转入老年代,如果空间不足保证所有的 survivor区域里的话说明不安全,因为很有可能MinorGC后晋升老年代,老年代根本存不下,所以这个时候要查看是否设置了允许冒这个险,(参数 HandlerPromotionFailure)除了检测参数外还要查看是否大于以往晋升对象的平均值,都允许的话就开始冒险。如果不允许就先FullGC。
冒险:尝试把MinorGC后剩余的对象放入老年代,如果成功最好,省了FullGC,如果失败只能跑一次FullGC了,时间花销最大。
图解如下:
[jvm解析系列][五]类文件结构,魔数,最大最小版本号
上一会讲完了JVM的内存分配和垃圾回收策略我们该讲一讲如何组织一个class文件了
一个class文件怎么被加载运行的?
我们可以说java的野心很大,早在97年的时候JVM虚拟机规范中就说以后可以支持其他语言,到JDK1.7的时候基本已经实现了,怎么做到的呢?
这是JVM做的语言无关性即JAVA/Scala/JRuby等都可以编译成class文件,对于JVM而言我不管你之前什么文件反正我只要class文件就好了:
很明显的,我们这一章就是用来讲解class文件的结构的
这一块比较复杂,我们不做特别详细的讲解,大概了解一下class文件的结构即可
class文件时一组以8字节为基础单位的二进制流,它们中间的数据是按照顺便紧密排列的,也没有用到任何的分隔符,所以规则的理解相当重要。
Class文件格式采用一种类似于C语言的伪结构来存储数据,但是只有两种数据结构,分别是无符号数和表
1、无符号数:
无符号数分别以u1,u2.u4,u8来表示1248个字节,无符号数主要用来描述数字,引用,字符串。
2、表:
表是由无符号数或者其他表作为item的复合的数据结构,这种数据结构是具有层次的(可以类比Java里的list<T>还是带泛形的这种)。于是我们可以说class本身就是一种表,复杂的表,里面包含如下的内容:
虽然画的是烂了点,但是这幅图还是能够表达出一个class的结构,这幅图从左往右,从上往下顺序排列就是class的结构。
在接下来的任务里我们就是一个个的讲解这个大的图。
1、magic魔数
作用:用来确定这个文件是否为一个能被虚拟机接受的Class文件。说白了这个东西的作用跟后缀名.class一样,只是怕你乱改后缀名所以加一个魔数免得你改一个.png成.class去骗他,class文件的魔数为:0xCAFEBABE。
2、minor_version和major_version
作用:确定版本号,minor是次版本号,major是主版本号,Java的版本号从45开始。jdk1.0使用了45.0~45.3之后的每一个版本加一,到jdk1.7的时候版本号是51.0。我截取以前写的类我们来看一看。
我已经给大家划出来了几个部分,怎么样没骗你们吧
[jvm解析系列][六]class里的常量池,访问标志,类的继承关系,如何把一个类在字节码中描述清楚?
上回我们说到了魔数和版本号,今天我们接着说。为了方便起见,我把那幅图拉过来方便大家看
由图可见接下来是一个叫constant_pool_count翻译过来叫常量池数量,前面我们说到class文件中只有两种数据结构,无符号数和表,而且整篇没有分隔符,在没有分隔符的情况下我们怎么区别数量非1的表的分界线呢。(举个例子:图中的constant_pool,我们想想该怎么标记出constant_pool的结束位置?)没错他的结束位置全都是靠前面的u2类型的constant_pool_count实现的,我打开一个class文件大家看一下。
这里我把43划掉了为什么呢?因为设计者把0空出来了也就说常量池一共有[1,43)个常量所以是42。(只有常量池0空缺,其余都从0开始)
也就是说接下来有42个cp_info表类型的数据结构,那么我们来看一下cp_info怎么构成的。
常量池中我们存什么?字面量和符号引用。
1、字面量,百度百科中这样解释:
其中有几个关键字,源码中,固定值。仔细阅读下来应该跟Java中常量差不多。不过它们的区别在于。字面量一般为右值也就是说int a = 0;这个0就是字面量。
2、符号引用,一般指类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。说白了也就是类和接口的名字以及字段的名字和类型,方法的名字和返回值等能标志出自己的东西。
我们新开一个小一点的类,来仔细看一下它的结构(这个类只实现了一个main方法,很简单的一个类,没有其他变量,静态变量存在,之后解析都是这类)
先上类图:
类的常量池count和常量池:
我们可以看到它的常量池是0x10转化成十进制就是16再去掉0位就是一共15个常量
我们使用javap -verbose xxxx.class来看一下这个class的结构。
我们可以看出来第一列有很多类似于Class,Utf的名称,这个就是cp_info的表结构,这个表结构有一个特殊的地方,就是开始都会有一个u1的标志位。里面一共有11种类型,在jdk1.7后新增了3中暂且不表。着11种类型,这11种类型我们可以分为两种。
1、引用类型:上图中的Class,Methodref,NameAndType以及不在上面的Fieldref和InterfaceMethodref
2、字面量:Utf8,interger float long double string
引用类型的意思是这些类型是引用其他类型的,这样说不好理解我们举个例子,看图#1是class类型他引用了#2,#2是utf8类型代表了Class的限定名。
关于这11个详细的结构网上有很多,我们的class解析是初步解析,以后我可能会加进来,这次暂且不加。其实这一块很简单结合javap完全可以看得懂,相信大家多看看也可以看懂。
那么根据最上面的图,结束了常量池之后就是访问标志。这个类用来形容这个class文件是类还是借口,是public还是abstract,是不是有final修饰符呢。同样的,我们打开一个class文件来看一下
图中所示访问标志位0x0021,那么我们怎么看出来它的信息呢?我送你一张表来看:
请叫我灵魂画师,上面还残留了我输入的框框忘了点掉了。。。。。。
那么根据上图来看我们刚刚剖析的那个类应该占用了public和super两个所以值是0x0021
再根据最上面那个图来看看呢我们接下来应该讲this_class和super_class还有一个不定长的interfaces,关于interfaces这种结构我们在常量池中已经讲过了。其实我们分析到现在,关于一个类的描述也基本完结了(下一节讲方法),除了类的继承关系没讲,那我们就把泪的继承关系讲完来结束掉他吧。
1、this_class顾名思义,其实指向的就是类的全限定名
2、super_class,这个讲的就是弗雷德全限定名了
3、interface,当然就是说接口了。
来来来,我给大家看一下图
00
一不小心少截了一个00,我自己打字补上的,其实thisclass是01,superclass是03,接口没有继承所以count是00。我们看看之前我们的常量值都索引到哪里去了
可以看到01是指向Test类的,03指向Object类跟我们预测的一样,到此我们的类的描述就算讲完了,出了常量池中那11个表结构没有讲,但是我们一般借助javap工具是不需要了解那些表结构的,之后有时间再补上
[jvm解析系列][七]字段表集合、volatile、volatile和属性表集合,你的变量该如何存储?
上段我们说到一个类的基本构成,今天我们来讲一下类的内部结构。类的内部一般情况下由类的成员变量(类字段),方法和内部类等构成。
1、字段表:
不好意思我们这一次需要先上字段表的内容然后一个一个讲解,不能像前面那样像设计者一样一个一个的推出来了,主要原因还是字段表里包含了属性表。
access_flags
还记得我们在上一篇里讲的,在类的继承关系之后应该就是field_info他主要描述接口或者类中声明的变量。field包括类和实例级变量(声明在方法外的变量)。那么我们想一下一个成员变量应该有什么?我们平时这样声明一个变量。如:private int a = 0;所以,首先要有的应该是一个作用域(public private protected),接下来是一个int,这个被叫做字段数据类型(也就是int或者其他类型,还有对象,数组)。当然有时候我们还会申请这样一个变量:public static final int a =0;那么我们应该还有一个用来表示他是否是final的变量,被叫做可变性。还有一个static变量需要描述。当然不能缺少了一个enum类型的变量,有时候我们还会见到一些不常见的修饰符,如volatile、transient。
volatile关乎到jvm的变量赋值问题,在jvm中,a++这个方法不是每次都要去堆内直接操作内存的,他被编译成了四个部分
1、把内存里的变量复制到自己的方法栈内
2、进行完修改,这里有赋值和加两部份
3、再赋值回去。这样如果有A,B两个线程同时访问变量a,并且同时修改+1,在返回堆中的时候a并不会+2而是+1。
所以volatile变量声明的变量可以保证每次读取的数据都是最新数据,但是注意,不可以保证原子性。
除此之外volatile还可以保证指令重排优化,这里我们以后会讲。
2、transient,在序列化的时候,被transient修饰的变量不参与序列化,默认不被transient修饰。
综上所述,一个字段表应该包括字段的作用域,static,final,volatile,transient,是否由编译器自动生成的,字段是否enum。如下图所示
name_index,descriptor_index
前面我们已经说过了,这个位置应该是一个field_info的表,既然是一个表就不可能仅仅只有上面一个最多u2类型的字段构成。所以除此之外我们还需要字段的简单名称和描述符。
1、简单名称就是如int a;a就是简单名称
2、描述符:描述符主要用来描述字段的数据类型、方法的参数列表和返回值。这一块不在JVM范围内,后面会在杂项中补充。
attributes
最后也就是一个需要细细讲解的部分,属性,在Class文件,字段表,放发表中都可以携带自己的属性表集合。
一个属性应该是下列这样的结构:
attribute_name_index 代表了一个常量池关于属性名称的索引。
而info代表了每一个属性的内容,我们并不强求info的长度,我们只需要一个u4类型的length来表示这个属性到底有多长就可以了。
而一个字段表内可以声明如下的属性:
关于每一个属性表的具体结构,我们会到放到下一章,因为方法表中也有属性,综合在一起讲一些比较常见的属性。
[jvm解析系列][八]方法表集合,Code属性和Exceptions属性,你的字节码存在哪里了?
根据我们第五章的总构图来看,这一章我们正该讲到方法表集合:
大家可能注意到在 java 中声明一个方法和声明一个变量很相似,public int a = 0; 和 public int a (){}; 于是在方法表集合中和字段表集合也很相似。
一个方法表的结构应当和下图一样:
对比字段表应该发现几乎是一样的。access_flags 里的可选项略有不同而已。
access_flags:
这样以来我们把方法表和字段表对比来看应该很好理解了。对于属性表又是一大块内容。上次我们说到了属性表的结构
并且说了在字段表中常用的属性表。同样的今天我们贴出来在方法表中几个重要的属性表并详细讲解一下
在这里我们就贴了两个比较常见的属性:
1、Code
Java 程序方法中的代码经过 javac 编译之后形成字节码存在了 Code 属性内,Code 属性存在方法表集合内
code 属性表结构如下:
从图上看 max_stack 属性,我们之前讲过,一个字节码的执行是依靠栈的,所以 max_stack 就是栈的最大深度
max_locals 代表了局部变量表的所需空间,单位是 Slot 是虚拟机为局部变量分配内存所使用的最小单位。
code 才是真正用来存放字节码指令的,每一个 code 占用 u1 类型,也就是 0~255,就是说 java 最多可以表达 256 条指令,目前 java 只有 200 条左右的指令
exception 对有的方法可以有,有的方法可以无,并不是一个必须的表,异常表的姐哦股如下图所示(注意跟 Exception 属性分开)
很明显多的他对应着 try catch 这种东西,start_pc 和 end_pc 划分了 try{},而 catch_type 代表了 catch(exception)里面的那个参数 exception,如果抓到异常就转到 handler_pc 处理。
不好意思各位我需要出门一趟,下午回来继续更新。已经回来了,继续更新。
除却了 Code 属性,接下来就是 Exceptions 属性了,它的表结构如下:
前两个不用解释了。
第三个 number_of_exceptions 表示了这个方法可能抛出 number_of_exceptions 种一场,其中的每一个一场就用 exception_index_table 表示,他只想了常量池中 Constant_class_info 类型的索引。
[jvm解析系列][九]类的加载过程和类的初始化。你的类该怎么执行?为什么需要ClassLoader?
通过前面好几章的或详细或不详细的介绍,我们终于把字节码的结构分析的差不多了。现在我们面临这样一个问题,如何运行一个字节码文件呢?
首先,java语言不同于其他的编译时需要进行链接工作的语言不通,java语言有一个很明显的特性,那就是动态加载,一个字节码的加载往往都是在程序运行的时候加载进来的,很多时候这种方式给我们带来了便利。虽然从某种意义上来说他可能消耗了一定的资源降低了性能。
类的生命周期?
没错,一个类的生命周期,在很多人眼里可能类天生都摆在那里了,随着程序生,随着程序死。但是事实情况并不是这样,java语言的动态加载要求了一个类肯定有他的生命周期,一个类的生命周期有7个阶段,如下图所示:
这个图里第二行我特意放了三个并排就是因为第二行可以统称为链接,这中间遵循了严格的开始顺序,但是解析是有可能在初始化之后开始,这也是为了java语言的动态绑定而做的改变。并且这中间只有开始的先后关系,没有结束的先后关系。
首先我们说一说第一个情况加载:什么时候开始一个类的加载过程,jvm规范没有规定,不同的虚拟机有不同的实现。
加载这个过程主要是通过一个类的权限定名获取类的二进制字节流
然后把字节流转化为一种方法区运行时的数据结构
最后在内存中形成一个Class对象
图示:
这里面我们可操作性比较强的也就是加载字节码这个过程了,我们都很熟悉一个方法叫做loadClass,没错他就是用来加载字节码的,我们可以自定义一个类加载器重写loadClass方法去控制字节流的加载方式,于是我们可以从网络上获取,从数据库获取,从文件中获取,还有一种动态代理技术是通过计算生成的。
加载完成后就是验证了。
验证主要是确保Class文件里没有坑爹的东西,不会损害虚拟机自身。大概就是分为4个验证流程。
1、文件格式验证:我们之前讲过的魔数和大小version就是在这个位置验证的,常量池中是不是所有类型都支持,Constant_utf8_info是不是都是UTF8编码等等。这一阶段的目的主要是验证输入的字节流能够正确的解析,保证格式上的正确,如果通过了这个验证就把字节流加入到了方法区中,下面的验证都是对方法区的验证。(给几个名词链接魔数,大小版本号,方法区)
2、元数据验证:这一个阶段主要是看看元数据是否符合语义,像父类是否继承了不被允许的类(final类),是不是实现了父类和接口中所有要实现的方法等。
3、字节码验证;这个阶段的验证,主要是看看逻辑上有没有错误,比如有一个跳转指令跳到了方法的外部这种。
4、符号引用验证:这个大家应该很常见,比较常见的就是是不是能访问到某个类(不是private和protected),通过字符串描述的权限定名能不能找到对应的类。
在验证之后,我们就会开始准备。
在准备阶段里,会为类变量分配内存并且设置类变量(static修饰的变量)的初始值,而且诸如static int a = 1;这种情况,在准备阶段是不会赋值1的。而是赋值最基本的初始值0,因为1需要时初始化的时候在类的构造器中调用。
但是,如果字段变量被final修饰,这个字段表就会存在一个ConstantValue属性(详见ConstantValue),在这个时候这个变量就会被赋值,如static final int a = 1;这时候a就会被赋值为1;
在准备之后,jvm会开始解析过程。
解析在通俗意义上讲就是把常量池里的符号引用替换成直接引用。首先我们来解释一下这两个名词的意思
1、符号引用:符号引用就是指使用一组符号来进行引用,被引用的目标不一定加载到内存中
2、直接引用:直接引用是指直接指向目标的指针,相对偏移量或是句柄。
对于解析,jvm并没有具体规定什么时候执行,只要在操作符号引用之前进行解析就可以了。所以具体的实现还要看jvm是怎么设计的(在加载时解析还是在使用前解析)
接下来就是初始化了
我们之前说过,一个类的加载时间是要依靠具体的虚拟机而定,但是遇到主动引用时加载验证准备工作必须完结
有五种情况,我们把这五种情况叫做主动引用。(这之前已经进行了加载验证准备,遇到下面情况直接初始化即可)
1、遇到new、getstatic、putstatic和invokestatic这4条字节码时,对应到java语言就是new了一个类,调用了一个类的静态字段(被final修饰的已经在常量池了,不算)
2、使用java.lang.reflect包对类进行反射条用的时候。
3、初始化一个类,他的父类没有进行过初始化的时候,需要先初始化他的父类。
4、虚拟机启动的那个执行类(也就是带有public void main(String[] args){}方法的那个类)
5、jdk1.7之后新增特性动态语言支持,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个类没有初始化的时候。
当然除了这五个主动引用以外,其余所有方法都不会出发初始化,我们叫做被动引用,下面举几个例子。
1、通过子类引用父类的静态字段,不会触发子类的初始化。
2、通过数组定义来引用类,不会触发类的初始化,他把原来的类翻译成了另外一个类,这个类带有一些数组元素的访问方法。
3、常量不会触发类的初始化。
初始化的过程:
在初始化的时候就是真正开始执行java代码的时候了,这个时候会执行一个叫做类构造器的东西(<client>())他负责收集类中所有的static{}然后合并,顺序和原顺序一样,在static{}中只能访问定义在静态语句块之前的变量,对于后面的变量只能赋值不能访问。
我们之前就讲过在初始化一个类的时候,如果他的父类没有进行过初始化,则需要先初始化他的父类。于是父类的<client>()方法肯定优于子类执行,也就是说父类的静态代码块优于子类的静态代码块执行。但是接口的<client>()方法并不一定先于子类方法执行,因为父接口的<client>()方法是在调用时才执行的。
于是我们可以看出来,在整个加载过程中,程序员可以操作的部分仅仅只有ClassLoader(加载字节码)和初始化静态代码块部分。所以我们接下来就会讲ClassLoader
[jvm解析系列][十]类加载器和双亲委派模型,你真的了解ClassLoader吗?
上一章我们讲到,一个类加载到内存里我们可以操作的部分只有两个,一个是加载部分一个是static{},我相信static{}不用多讲了。
接下来我们就来解析一下ClassLoader即类加载器,他就是用来加载字节码到方法区的类。
当年出现ClassLoader这个东西动态加载类的字节码主要还是为了满足JavaApplet的需求。虽然后来JavaApplet挂掉了,但是ClassLoader这个形式还是保留了下来,而且活的很好。
类的相等和instanceOf:
来我们来写一个例子
public class ClassLoaderTest {
public static void main(String[] args) throws InstantiationException, IllegalAccessException, ClassNotFoundException{
ClassLoader loader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// TODO Auto-generated method stub
try {
String className = name.substring(name.lastIndexOf(".")+1)+".class";
InputStream is = getClass().getResourceAsStream(className);
if(is ==null){
return super.loadClass(name);
}
byte[] buffer = new byte[is.available()];
is.read(buffer);
return defineClass(name, buffer, 0, buffer.length);
} catch (Exception e) {
// TODO: handle exception
throw new ClassNotFoundException(name);
}
}
};
Object object = loader.loadClass("top.jjust.jvm.ClassLoaderTest").newInstance();
System.out.println(object.getClass());
System.out.println(object instanceof top.jjust.jvm.ClassLoaderTest);
}
}
上述代码先重写了一个loadClass方法,然后用重写的方法加载了自己这个类并生成实例。
两行输出结果如下
<span style="font-size:12px;">class top.jjust.jvm.ClassLoaderTest
false</span>
我们可以看到class是没错的,但是我们用重写loadClass读取的class文件却不被认为是原类的一个子类(可能说起来比较拗口,大家可以看看代码就明白了)
这边牵扯到一个相等的问题,判断两个类是否相等(equals、isAssgnableFrom、isInstance、instanceof)有一个前提就是:这两个类由同一个类加载器加载。如果两个不同的加载器加载,判断是一定不等的。
双亲委派模型
在jvm中,有两种不一样的类加载器,
一个是加载<JAVA_HOME>/lib下的文件,也就是jvm本身的类(并且jvm会识别名字,单纯放在目录下是没用的),也就是加载自身的类加载器
还有一种是加载其他类的类加载器。这个种类的类加载器又可以细分为扩展类加载器和应用程序类加载器。
扩展类加载器主要是加载<JAVA_HOME>/lib/ext文件下的类库而应用程序类加载器主要是加载用户类路径上指定的类库,平时getsystemClassLoader就是返回的它,程序里没有定义过自己的类加载器一般情况下也是用它。这几个类加载器我们用图片表示一下。
在类中加载需要按照一种层次,这种层次我们画在下面:
这是什么意思?也就是说碰到一个类需要加载时,先要把这个请求交给父类,直到顶层,如果Bootstrap ClassLoader说我不做这个,才会由下一层尝试加载。如果所有父类都不能加载,才会自己加载。
为什么要设计这种模型呢?我们具一格例子,就拿所有的父类Object讲,这个类一般是由Bootstrap ClassLoader加载的,如果不使用双亲委派模型,在自定义加载器中加载了这个Objcet,那么一个jvm中就会出现多个Objcet。而不是像双亲委派模型一样,都由Bootstrap ClassLoader加载,不会重复加载。
其实在重写方法的时候,建议重写findClass()方法而不是loadClass,下面是loadClass的源码
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
我们发现,如果我们重写loadClass则会破坏双亲委派模型。
[jvm解析系列][十一]字节码执行之栈帧,你的字节码是如何运行的?
在之前的章节中我们讲解了jvm的内存分配和管理,class的文件结构,就差之行了。那么从第十一章开始我们就开始讲java虚拟机是如何执行一个class文件的。
首先我们应该明确虚拟机是区别于物理机的一种说法,物理机的执行引擎是建立在处理器,硬件 ,指令集之上的。而我们的虚拟机则由自己实现。在虚拟机中大致分为两种执行方式:解释执行和编译执行。
我们之前讲过,虚拟机运行方法的时候运行在java虚拟机栈里面,里面的存储结构是栈帧,需要了解一个虚拟机如何运行字节码文件的,首先我们需要了解一个栈帧的结构。
栈帧:
栈帧作为一个方法的代替者(在执行时)它肯定需要有方法的所有特征。那么我们在方法中定义的变量和方法传递的参数是不能缺少的。这一部分在栈帧中叫做局部变量表,其次每一个+-*/和其余的各种赋值等操作还需要占有一个区域,这个区域叫做操作数栈,在运行的过程中我们仍然有可能调用了其他的方法,这个时候我们需要一个类似于指针的引用去寻找方法的入口,但是我们直到在jvm中所有的引用都是用符号引用实现的,所以我们必须还需要一个动态链接的部分在运行时动态的把符号引用转成内存地址。剩下的还有返回信息对应了一个方法返回地址。所以我们得到了如下的一种数据结构,成为栈帧。
接下来我们来详细讲解每一部分:
局部变量表:
主要用于存放方法参数和方法内部定义的局部变量。我们之前分析过方法表,方法表中Code属性有一个参数是max_locals就定义了这个方法所需要分配的局部变量表的最大值。
局部变量表使用Slot作为最小单位。jvm中说一个Slot应该能存放一个boolean ,byte,char,short,int,float,reference,returnAddress类型的数据,所以Slot并没有一个固定的大小,随着上述类型的数据的长度不同,Slot的长度也不同。在Jvm中上述数据类型占用32位,所以Slot也占用了32位。如果遇到64位的数据类型Slot选择以高位对齐的方式分配两个连续的Slot空间。在执行的时候使用索引来寻找Slot,Slot同样也是可以复用的,因为一个Slot有可能不占用整个方法的生命周期,但是这种复用会影响GC。我们举个例子:
public class SlotGCTest {
public static void main(String[] args) {
// TODO Auto-generated method stub
byte[] holder = new byte[64*1024*1024];
System.gc();
}
}
使用上面的代码进行垃圾回收,应该符合我们的预期这64mb的内存不会被回收,因为依然可以通过GCROOT找到。使用-verbose:gc参数输出结果如下:
[GC (System.gc()) 67502K->65976K(188416K), 0.0007246 secs]
[Full GC (System.gc()) 65976K->65811K(188416K), 0.0039451 secs]
但是我们把gc的代码放在方法外呢?
public class SlotGCTest {
public static void main(String[] args) {
// TODO Auto-generated method stub
{
System.out.println("构造代码块");
byte[] holder = new byte[64*1024*1024];
}
System.out.println("main");
System.gc();
}
}
我们从上述代码中可以看出,我们gc的位置已经在构造代码块之外了,GCROOT应该找不到holder了,这64mb的内存必定会被回收。输出如下:
构造代码块
main
[GC (System.gc()) 67502K->65928K(188416K), 0.0018770 secs]
[Full GC (System.gc()) 65928K->65811K(188416K), 0.0057137 secs]
我就问你蒙蔽吗?为什么还是没有回收呢?这就是Slot复用对GC的影响了,我们稍微加一行代码
public class SlotGCTest {
public static void main(String[] args) {
// TODO Auto-generated method stub
{
System.out.println("构造代码块");
byte[] holder = new byte[64*1024*1024];
}
int a = 0;
System.out.println("main");
System.gc();
}
}
我们只加了int a = 0 ;这一行代码。最后输出如下:
构造代码块
main
[GC (System.gc()) 67502K->65992K(188416K), 0.0011489 secs]
[Full GC (System.gc()) 65992K->275K(188416K), 0.0038030 secs]
你会发现在Full GC中居然回收了。这就是因为虽然64mb的Slot已经离开了作用域但是Slot没有被覆盖,所以GCROOT中的局部变量表依然保存着holder的关联。所以我们有一个置空的说法,即holder = null;
在我们生成类变量的时候,会先在准备阶段赋值一次,在初始化的时候第二次赋值所以类变量直接int a而不赋值是可行的。
如果是局部变量不赋值,结果如下:
局部变量不赋值会报错。
操作数栈:
操作数栈是一个标准的栈,遵循后入先出的原则,和上一个参数,局部变量表一样,它的栈深也需要存在Code属性的max_stacks数据相中,操作数栈的元素可以是任意的Java数据类型。我们举个例子a++;在栈中是这样操作的。
动态链接:
链接分为两种,静态链接和动态链接,在编译期可知,运行期不变的属于静态链接,主要有static方法,私有方法,实例构造器和父类方法。动态链接因为不能确定具体调用的方法地址,所以往往要到执行时才会转换成内存,关于动态链接的具体内容因为篇幅问题我们之后会单独讲。
方法返回地址:
一个方法的结束只有两个情况,一个是return了(或执行完毕),另一种情况是遇到异常了,还throw出去了。一个方法结束之后肯定要返回之前调用方法的位置,所以我们需要记录一些信息,来存储调用位置的执行状态。一般情况下本方法执行完毕后,恢复调用方法的栈帧,并且压入返回值到操作数栈(如果有的话),最后把PC+1.
[jvm解析系列][十二]分派,重载和重写,查看字节码带你深入了解分派的过程。
重载和重写是分派中的两个重要体现,也是因为这个原因我们才把重载和重写写在了标题上。这一章我们的很多部分都在代码试验上。
总的来说分派分为静态分派和动态分派两种。
静态分派:
首先我们来看一段源码:
public class Dispatch {
public static void main(String[] args){
Animal a = new Dog();
Animal b = new Cat();
sound(a);
sound(b);
}
public static void sound(Animal a){
System.out.println("....");
}
public static void sound(Dog a){
System.out.println("wang..");
}
public static void sound(Cat a){
System.out.println("miao..");
}
static class Animal{
}
static class Dog extends Animal{
}
static class Cat extends Animal{
}
}
这段源码就是写了三个重载的方法,根据参数的不同输出不同的信息。应该大家都能知道正确输出,输出如下:
....
....
Animal a = new Dog();在这个里面我们把Animal叫做外观类型(静态类型)把Dog叫做实际类型。静态类型是在编译期可知的,但是一个对象的实际类型要在运行期才可知。而jvm在重载的时候通过参数的静态类型作为判定依据。我们来看一下字节码是不是和我们想的一样
看到图中invokestatic的字符引用,我们应该就知道了编译时期确实完成了方法的定位。
而这些需要靠静态类型定位方法的情形称为静态分派,静态分派发生在编译阶段。也就是说在这个方法执行之前仅仅在编译阶段,sound方法就认定了最后要输出一个"...."而不是wang和miao。
动态分派:
我们依然先给一段代码:
public class Dispatch {
public static void main(String[] args){
Animal a = new Dog();
Animal b = new Cat();
a.sound();
b.sound();
}
static class Animal{
public void sound(){
System.out.println("......");
}
}
static class Dog extends Animal{
public void sound(){
System.out.println("wang!");
}
}
static class Cat extends Animal{
public void sound(){
System.out.println("miao!");
}
}
}
这一段代码的输出结果相信我不用贴大家都明白。
wang!
miao!
但是,为什么jvm能够找到这一段方法并且执行的呢?
我们来看一下javap的输出信息
图中画红线的两个部分就是编译后的字节码文件了,他们调用的依然是animal的sound方法,看来动态分派不是在编译期完成的。问题就出在了invokevirtual上面了
jvm运行invokevirtual方法有一个过程,大致如下。
1、找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2、如果C与常量中的描述符和简单名称都符合的方法,说明这个方法就是我们找的,只需要进行访问权限校验(看看是不是private之类)。
3、如果不符合描述符或简单名称。则对C的父类进行第二步中的搜索。
4、最终都没有找到抛出AbstractMethodError。
可能第3步比较难懂,我们再来仔细说明一下
如果我们把代码修改如下:
public class Dispatch {
public static void main(String[] args){
Animal a = new Dog();
Animal b = new Cat();
a.sound();
b.sound();
}
static class Animal{
public void sound(){
System.out.println("......");
}
}
static class Dog extends Animal{
public void sound(String a){//修改后的方法
System.out.println("wang!");
}
}
static class Cat extends Animal{
public void sound(){
System.out.println("miao!");
}
}
}
那么很明显上述中的C在Animal a = new Dog();中就是Dog类,Dog类中只有一个sound(string)的方法并不存在一个sound()方法,所以去C的父类中即Animal类中找到sound方法,最终输出如下:
......
miao!