正文
一、JVM内存模型
内存划分
JVM内存共分为堆、虚拟机栈,方法区,本地方法栈、程序计数器(寄存器)。
堆:被所有线程共享的一块内存区域,在虚拟机启动的时候创建,用于存放对象实例。
虚拟机栈:是线程私有的。每个方法在执行的时候都会创建一个栈帧,栈帧存储了局部变量,操作数栈,动态链接,方法返回地址。
局部变量表:
局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型。局部变量表所需的内存空间在编译期确定,当进入一个方法时,方法在栈帧中所需要分配的局部变量控件是完全确定的,不可动态改变大小。
操作数栈:
后进先出LIFO,最大深度由编译期确定。栈帧刚建立使,操作数栈为空,执行方法操作时,操作数栈用于存放JVM从局部变量表复制的常量或者变量,提供提取,及结果入栈,也用于存放调用方法需要的参数及接受方法返回的结果。
操作数栈可以存放一个jvm中定义的任意数据类型的值。
在任意时刻,操作数栈都一个固定的栈深度,基本类型除了long、double占用两个深度,其它占用一个深度
动态连接:
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如final、static域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
方法返回地址:
当一个方法被执行后,有两种方式退出该方法:执行引擎遇到了任意一个方法返回的字节码指令(lreturn、freturn、dreturn以及areturn)或遇到了异常,并且该异常没有在方法体内得到处理。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值,而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。
方法区:线程共享的一块内存区域,用于存储已经被虚拟机加载的类信息,常量,静态变量等。
本地方法栈:线程私有的,与虚拟机栈类似,主要为虚拟机使用到的Native方法服务。
程序计数器:线程私有的,程序计数器指当前正在执行的字节码的行号。如果是Native方法,则为空。
对象创建
1、类加载检查: 虚拟机遇到一条 new 指令时,首先会去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
2、分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式:
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。
3、初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
4、设置对象头: 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、哈希值、 gc分代年龄 、锁状态标志、 线程持有的锁。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
5、执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
常量池
Java 基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean;这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。
两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。String也实现了常量池。比如:
public static void main(String[] args) { String a="123"; String b="123"; String c=new String("123"); System.out.println(a==b); //true System.out.println(a.equals(c));//true }
因为a和b都是从常量池内取值,所以这俩个值相等,那a和c不应该返回true啊,因为这俩对象在堆中的引用地址一定不同啊。这个时候需要一个新的知识点,==和equal的区别
基础数据类型(Byte,Short,Integer,Long,Character,Boolean),== 与 equal 都是作用于比较对象内容(堆)是否相同。
引用对象类型, == 与 equal 都是作用于比较对象内存地址(栈)是否相同。
那既然是这样a与c更应该是false。所有的类都继承Object类,如果不重写equals(),默认执行的是Object的equals()方法
public boolean equals(Object obj) { return (this == obj); }
String 类重写了equals()方法,所以a,c是true。
public boolean equals(Object anObject) { if (this == anObject) { return true; } if (anObject instanceof String) { String anotherString = (String)anObject; int n = value.length; if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { if (v1[i] != v2[i]) return false; i++; } return true; } } return false; }
对于基本数据类型,(Byte,Short,Integer,Long,Character,Boolean)这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。而Float和Double则没有。所以创建对象后的引用地址必然不同。
public static void main(String[] args) { Integer a=300; Integer b=300; Integer c=30; Integer d=30; System.out.println(a==b);//false System.out.println(c==d);//true }
总结:相同内容的对象地址不一定相同,但相同地址的对象内容一定相同。
二、类加载
类加载过程
当程序主动使用某个类时,如果该类还没有被加载到内存,则JVM会通过加载、连接、初始化来对这个类进行初始化。
类加载生命周期
加载
加载,是指Java虚拟机查找字节流(查找.class文件),并且根据字节流创建java.lang.Class对象的过程。这个过程,将类的.class文件中的二进制数据读入内存,放在运行时区域的方法区内。然后在堆中创建java.lang.Class对象,用来封装类在方法区的数据结构。
类加载阶段:
(1)Java虚拟机将.class文件读入内存,并为之创建一个Class对象。
(2)任何类被使用时系统都会为其创建一个且仅有一个Class对象。
(3)这个Class对象描述了这个类创建出来的对象的所有信息,比如有哪些构造方法,都有哪些成员方法,都有哪些成员变量等。
验证
验证阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。整体来看,验证阶段大致分为4个验证动作。
1、文件格式验证
第一阶段是验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。比如是否以魔数开头,(为了方便虚拟机识别一个文件是否是class类型的文件,SUN公司规定每个class文件都必须以一个word(四个字节)作为开始,这个数字就是魔数。主、次版本号是否在当前虚拟机处理范围内;常量池的常量数据类型是否被支持。
2、元数据验证
元数据验证是对字节码描述信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。这个阶段可能的验证点:
a.是否有父类;
b.是否继承了不被允许继承的类;
c.如果该类不是抽象类,是否实现了其父类或接口要求实现的所有方法;
3、字节码验证
字节码验证的主要目的是通过数据流和控制流分析,确定程序语义的合法性和逻辑性。该阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事情。这个阶段可能的验证点:
a.保证任何时候操作数栈的数据类型与指令代码序列的一致性;
b.跳转指令不会跳转到方法体以外的字节码指令上;
4、符号引用验证
符号引用验证的主要目的是保证解析动作能正常执行,如果无法通过符号引用验证,则会抛出异常。这个阶段可能的验证点:
a.符号引用的类、字段、方法的访问性(public、private等)是否可被当前类访问;
b.指定类是否存在符合方法的字段描述符;
准备
为静态变量分配内存,并将其初始化为默认值。
注意:
public static int value = 1;在准备阶段的初始值是 0而不是1,而把value赋值的putstatic指令将在初始化阶段才会被执行。
特殊情况:
public static final int value = 1;//此时准备value赋值为1。
解析
解析阶段是虚拟机将常量池内的符号引用替换成直接引用的过程。直接引用是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。
初始化
当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。为初始化变量赋值,执行类构造器等。
创建对象
new关键字创建 Class a=new A();此方法会调用构造函数。
通过反射的实体类.newInstance(), Class.forName("com.xiaojie.entity.User") 全限定类名。此方法会调用无参构造函数。
constructor.newInstance(); 此方法会调用构造函数。
clone()克隆方法。此方法不会调用构造函数。浅克隆是指拷贝对象时仅仅拷贝对象本身(包括对象中的基本变量),而不拷贝对象包含的引用指向的对象。深克隆不仅拷贝对象本身,而且拷贝对象包含的引用指向的所有对象。
使用反序列化,此方法可以进行深克隆,也不会调用构造函数。
package com.xiaojie.entity; /** * @Description: * @author: xiaojie * @date: 2021.09.22 */ public class User implements Cloneable{ private Long id; private String name; private Integer age; public void setId(Long id) { this.id = id; } public void setName(String name) { this.name = name; } public void setAge(Integer age) { this.age = age; } public String getName() { return name; } public Integer getAge() { return age; } public Long getId() { return id; } public User(Long id, String name, Integer age) { this.id = id; this.name = name; this.age = age; System.out.println("我是有参构造函数。。。。。。"); } public User() { System.out.println("我是无参的构造函数。。。。。"); } @Override public Object clone() throws CloneNotSupportedException { //如果要进行深克隆,需要在此处重写clone()方法,对引用型对象进行克隆 return super.clone(); } }
public static void main(String[] args) throws InstantiationException, IllegalAccessException, ClassNotFoundException, NoSuchMethodException, InvocationTargetException, CloneNotSupportedException, IOException { //1、new 关键字 User user=new User();//调用无参构造函数 //2、通过反射的实体类.newInstance(), Class.forName("com.xiaojie.entity.User") 全限定类名 User user1 = User.class.newInstance();//调用无参构造函数 Class<?> aClass = Class.forName("com.xiaojie.entity.User"); User user2 = (User) aClass.newInstance();//调用无参构造函数 //3、constructor.newInstance(); Constructor<User> constructor = User.class.getConstructor(); User user3 = constructor.newInstance(); //调用无参构造函数 Constructor<User> constructor1 =User.class.getConstructor(Long.class,String.class,Integer.class); User user5 = constructor1.newInstance(1L, "tom", 18);//调用有参构造函数 //4、使用clone方法 不会调用构造器 User user4= (User) user5.clone(); System.out.println(user4);//com.xiaojie.entity.User@f6f4d33 System.out.println(user5);//com.xiaojie.entity.User@23fc625e 可见复制后的对象并不相等,但是对象的属性值是一样的, System.out.println(user5.getName()==user4.getName()); //true //浅克隆是指拷贝对象时仅仅拷贝对象本身(包括对象中的基本变量),而不拷贝对象包含的引用指向的对象。 //深克隆不仅拷贝对象本身,而且拷贝对象包含的引用指向的所有对象。 //5、使反序列化,反序列化可以进行深克隆 不会调用构造器 ObjectInputStream in = new ObjectInputStream(new FileInputStream("")); User user6 = (User) in.readObject(); }
类加载器
类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。一旦一个类被加载到JVM中,同一个类就不会被再次载入了。
JVM预定义的有三种类加载器
根类加载器(Bootstrap ClassLoader):或者叫启动类加载器,它用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader(负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
扩展类加载器(Extension ClassLoader):它负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。由Java语言实现,父类加载器为null。
系统类加载器(Application ClassLoader):被称为系统(也称为应用程序)类加载器,它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH换将变量所指定的JAR包和类路径。程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由Java语言实现,父类加载器为ExtClassLoader。
类加载器加载类的大致步骤
JVM的类加载机制主要有如下3种
全盘负责:所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
双亲委派:所谓的双亲委派,则是先让父类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。
缓存机制:缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这就是为很么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。
在加载之前会判断缓存区是否存在该类对象,如果存在则直接返回相应的对象。
如果不存在,则判断该类加载器是否有父类加载器,或者自己是一个父类加载器,根加载器。
如果有父类加载器,则委托父类加载器去加载(如果父类有父类依次递归)。如果父类加载器没有找到该类,则自己去加载该类,加载成功返回,加载失败,抛出ClassNotFoundExcepton的异常。
如果是根类加载器则利用根类加载器加载对应的对象。加载成功返回,加载失败,抛出ClassNotFoundExcepton的异常。
双亲委派模式
双亲委派机制,其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,这就是双亲委派模式。
双亲委派模式的好处:采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。再一个是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
如何破坏双亲委派模式
双亲委派代码
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 检查该类是否已经加载过。如果加载过就直接返回 Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { //没加载过,调用父类加载器去加载,递归调用 c = parent.loadClass(name, false); } else { //没有父类就启动启动类去加载,这是个native方法 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; } }
破坏双亲委派有两种方式
1、自定义类加载器,重写findClass();
package com.xiaojie.classloader; import java.io.*; import java.nio.ByteBuffer; import java.nio.channels.Channels; import java.nio.channels.FileChannel; import java.nio.channels.WritableByteChannel; /** * @Description: 自定义类加载器 * 使用场景 * (1)加密:Java代码可以轻易的被反编译,如果你需要把自己的代码进行加密以防止反编译, * 可以先将编译后的代码用某种加密算法加密,类加密后就不能再用Java的ClassLoader去加载类了, * 这时就需要自定义ClassLoader在加载类的时候先解密类,然后再加载。 * * (2)从非标准的来源加载代码:如果你的字节码是放在数据库、甚至是在云端, * 就可以自定义类加载器,从指定的来源加载类。 * * (3)以上两种情况在实际中的综合运用:比如你的应用需要通过网络来传输 Java 类的字节码, * 为了安全性,这些字节码经过了加密处理。这个时候你就需要自定义类加载器来从某个网络地址上读取 * 加密后的字节代码,接着进行解密和验证,最后定义出在Java虚拟机中运行的类。 * @author: xiaojie * @date: 2021.09.23 */ public class MyClassLoader extends ClassLoader { public MyClassLoader() { } public MyClassLoader(ClassLoader parent) { super(parent); } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { File file = new File("D:/People.class"); try { byte[] bytes = getClassBytes(file); //defineClass方法可以把二进制流字节组成的文件转换为一个java.lang.Class Class<?> c = this.defineClass(name, bytes, 0, bytes.length); return c; } catch (Exception e) { e.printStackTrace(); } return super.findClass(name); } private byte[] getClassBytes(File file) throws Exception { // 这里要读入.class的字节,因此要使用字节流 FileInputStream fis = new FileInputStream(file); FileChannel fc = fis.getChannel(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); WritableByteChannel wbc = Channels.newChannel(baos); ByteBuffer by = ByteBuffer.allocate(1024); while (true) { int i = fc.read(by); if (i == 0 || i == -1) break; by.flip(); wbc.write(by); by.clear(); } fis.close(); return baos.toByteArray(); } public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException { MyClassLoader mcl = new MyClassLoader(); Class<?> clazz = Class.forName("People", true, mcl); Object obj = clazz.newInstance(); System.out.println(obj); System.out.println("使用的类加载器是:" + obj.getClass().getClassLoader()); } } 1、
2、使用线程上下文类加载器。典型案例如JDBC连接,通过应用程序类加载器加载。
ClassLoader loader = Thread.currentThread().getContextClassLoader();