JVM
说到jvm我们可以比较一下c语言
c的执行过程:c源文件===》tcc编译成机器码指令===》连接类库===》机器执行编译后的机器码指令
java的执行过程:java源文件===》javac编译成.class字节码文件===》jvm将字节码文件加载到自己虚拟的内存中===》解释器逐行解释字节码文件,转换成机器码===》机器执行机器码指令
为什么要JVM
c编译的机器码文件是计算机本身可以认识并执行的代码,但是java编译后的.class字节码文件是机器不认识的,所以有了虚拟机,虚拟机屏蔽了程序与操作系统的交互,让程序可以在虚拟机自己虚拟的内存中运行,这也是java语言一次编译到处运行的关键。
什么是JVM
JVM:Java Virtual Machine-java程序运行的环境(java二进制字节码.class文件的运行环境)
好处:
- 一次编写,到处运行
- 自动内存管理机制,提供垃圾回收功能
- 数组下标越界越界检查
- 多态
JRE:Java Runtime Environment-java运行环境(包含jvm)主要是由java基本类库,例如Java.util.* ,Java.lang.* 和jvm构成。
JDK:Java Development Kit-java开发环境(包含jre),主要是由java开发工具,如javac编译源码工具,和jre构成。
JVM内存结构
线程私有:
java代码是在jvm中解释执行,jvm指令只有解释器才能读懂,解释器将jvm指令翻译成机器码指令,再由机器执行,那么就需要有解释器和程序计数器
程序计数器:
用来记住下一条jvm指令的执行地址,不会出现内存溢出
虚拟机栈(线程栈,方法栈):
-
每个线程运行所需要的内存,称为虚拟机栈
-
每一个栈由一个或多个栈帧组成,对应每次方法调用时所占用的内存
-
每一个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
-
当方法执行完毕,方法对应的栈帧也就被弹出栈(也就是该方法对应的栈内存被释放)。每一块栈帧包含
局部变量表、操作数栈、动态连接、方法出口
。
局部变量表:
顾名思义就是局部变量的表,用于存放我们的局部变量的,主要存放我们的 Java 的八大基础数据类型,如果是局部的一些对象,比如我们的 Object 对象,我们只需要存放它的一个引用地址即可。
操作数栈:
存放 java 方法执行的操作数的,方法中需要用到的数据,都会压入操作数栈中进行操作。
public class Test {
public static void main(String[] args) {
Test test = new Test();
test.stackTest();
}
public void stackTest(){
int age = 18;
int year = 2;
int now = age+year;
}
}
Compiled from "Test.java"
public class com.cy.Test {
public com.cy.Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class com/cy/Test
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method stackTest:()V
12: return
public void stackTest();
Code:
0: bipush 18
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: return
}
当运行到stackTest()方法时,0: bipush 18
首先会将一个值为18的数字加载到操作数栈,2: istore_1
将第一步中放入到操作数栈的数字18放到局部变量表中,记录位置为1。3: bipush 20 5: istore_2
同上。再来看6: iload_1 7: iload_2
它们的含义是将局部变量表中位置为1和为2的两个数加载到操作数栈中,8: iadd
交给执行引擎运算,9: istore_3
运算完成之后将运算结果放入到局部变量表中,记录位置为3。
虚拟机栈大小的设置:
虚拟机栈的大小缺省为 1M,可用参数 –Xss (stack size)调整大小,例如-Xss256k。
虚拟机栈相关的程序异常:
- StackOverflowError异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常,通常是由无线递归导致的,也就是栈帧过多。
- OutOfMemoryError异常:如果java虚拟机的容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。这种情况基本很少出现。
问题辨析:
- 垃圾回收是否涉及栈内存?
答:不涉及,当方法执行完毕,方法对应的栈帧也就被弹出栈(也就是该方法对应的栈内存被释放)
- 栈内存分配的越大越好吗?
答:不是的,栈内存大小只能影响栈帧开辟多少,也就是方法嵌套调用的层级,方法递归调用的次数
- 方法内的局部变量是否线程安全?
答:1.如果局部变量是引用对象,并逃离方法的作用范围(也就是被作为返回值返回),需要考虑线程安全问题
2.如果该局部变量是作为方法参数传递进来,说明该方法以外的方法也能访问这个局部变量,需要考虑线程安全问题
线程运行诊断:
案例1:cup占用过多(Linux系统) 问题:代码中出现死循环
定位
- 用top定位哪个进程对cpu占用过高
- ps H -eo pid,tid,%cpu | grep 进程id(用ps命令进一步定位是哪一个线程占用的cpu过高)
- jstack 进程id
- 可以根据线程id定位到有问题的线程,进一步定位到源代码的行数
案例2:线程运行很长时间没有结果 问题:多线程中出现死锁
本地方法栈:
java本身不能直接和操作系统交互,那么java中包含了许多native方法,通常是由c或c++编写,这些方法运行所需要的内存就是本地方法栈。
线程共有:
堆:
堆是用来存储所有new出来的对象,所有的引用类型对象本身都存储在堆中,他们的引用存放在虚拟机栈中
我的问题:
类中new出来的对象和方法中new出来的对象(全局变量和局部变量),存储方式会不会不一样?
public class Test {
User user = new User();
public static void main(String[] args) {
Test test = new Test();
test.stackTest();
System.out.println(test.user);
}
public void stackTest(){
User user = new User();
System.out.println(user);
}
}
class User{
}
答:new出来的对象都是存放在堆内存中,只不过局部变量在方法运行new对象这行语句时就会将该对象的引用地址放到局部变量表中,这个引用只能在该方法中使用,方法执行完毕该对象的引用地址即被释放,但是new出来的对象还在堆中,只有GC触发才会将这个对象释放,全局变量只有在被使用时才会被将地址局部变量表中
堆相关的程序异常:
- OutOfMemoryError异常:创建的对象所占用的堆内存超过jvm配置的最大内存,且这些对象始终被强引用着,无法被GC回收,那么就会出现内存溢出错误OutOfMemoryError异常。
堆大小参数设置:
堆可以处于物理上不连续的内存空间,可以固定大小,也可以动态扩展,通过参数-Xms和-Xmx两个参数来控制堆内存的最小和最大值。
堆内存诊断工具:
- jps工具
- 查看当前系统中有哪些java进程
- jmap工具
- 查看某个时刻堆内存占用情况 jmap -heap 进程id
- jconsole工具
- 图形界面的,多功能的监测工具,可连续监测
- jvisualvm
- 图形界面的,多功能的监测工具,比jconsole功能更强大
方法区(JDK1.8元空间):
方法区是各个线程共享的内存区域,在虚拟机启动时创建。它存储每个类的结构,比如:运行时常量池、Class类信息、ClassLoader类加载器,这些数据在1.8之后全部存储在本地内存中,StringTable放在堆内存中。
方法区大小参数设置:
可以使用-XX:MaxMetaspaceSize=128m来设置方法区的大小
方法区相关的程序异常:
- OutOfMemoryError异常:Metaspace 由于加载了太多类导致方法区内存溢出,比如spring中AOP、mybatis获取Mapper对象,他们都会动态生成代理类,使用cglib在运行时动态加载类。
运行时常量池:
- 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
- 运行时常量池,常量池时*.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。
StringTable:
存放字符串常量池
public static void main(String[] args) {
//首先为StringTable分配空间 StringTable[]
String s1 = "a";
//去StringTable查找,没有就会将“a”放入到StringTable["a"]
String s2 = "b";
//去StringTable查找,没有就会将“b”放入到StringTable["a","b"]
String s3 = "ab";
//去StringTable查找,没有就会将“ab”放入到StringTable["a","b","ab"]
String s4 = s1+s2;
//new StringBuilder().append("a").append("b").toString()
//toString()方法是直接new String("ab")
System.out.println(s3==s4);//false s3是StringTable中的"ab" s4是new String("ab")
}
直接内存:
- 常见于NIO操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受JVM内存回收管理
垃圾回收:
如何判断对象可以被回收:
引用计数法:
在 JDK1.2 之前,使用的是引用计数器算法。
在对象中添加一个引用计数器,当有地方引用这个对象的时候,引用计数器的值就 +1 ,当引用失效的时
候,计数器的值就 -1,当引用计数器被减为零的时候,标志着这个对象已经没有引用了,可以回收了!
问题:
如果在 A 类中调用 B 类的方法, B 类中调用 A 类的方法,这样当其他所有的引用都消失了之后, A 和 B 还有
一个相互的引用,也就是说两个对象的引用计数器各为 1 ,而实际上这两个对象都已经没有额外的引用,
已经是垃圾了。但是该算法并不会计算出该类型的垃圾。
可达性分析算法:
通过一系列的称为 GC Roots 的对象作为起点 , 然后向下搜索 ; 搜索所走过的路径称为引用链 /Reference Chain, 当一个对象到 GC Roots 没有任何引用链相连时 , 即该对象不可达 , 也就说明此对象是不可用的 。
四种引用类型:
强引用:
通过关键字new创建的对象所关联的引用就是强引用。无论任何情况下,强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。如:
User user = new User();
软引用:
通过软引用对象SoftReference实现的引用称为软引用。
- 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用引用的对象
- 可以配合引用队列来释放软引用自身
ReferenceQueue<User> queue = new ReferenceQueue<>();
//关联了引用队列,当User对象被回收时,软引用自己会加入到queue中
SoftReference<User> ref = new SoftReference<>(new User(),queue);
//队列中有软引用就把它一个个释放
Reference<? extends User> poll = queue.poll();
while (poll!=null) {
queue.remove();
poll = queue.poll();
}
System.out.println(ref.get());
弱引用:
通过弱引用对象WeakReference实现的引用称为弱引用。
- 仅有弱引用引用该对象时,只要触发了GC,不管当前内存空间足够与否,都会回收弱引用引用的对象。
- 可以配合引用队列来释放弱引用自身
ReferenceQueue<User> queue = new ReferenceQueue<>();
//关联了引用队列,当User对象被回收时,弱引用自己会加入到queue中
WeakReference<User> ref = new WeakReference<>(new User(),queue);
//队列中有弱引用就把它一个个释放
Reference<? extends User> poll = queue.poll();
while (poll!=null) {
queue.remove();
poll = queue.poll();
}
System.out.println(ref.get());
虚引用:
通过虚引用对象PhantomReference实现的引用称为虚引用。
- 必须配合引用队列使用,主要配合ByteBuffer使用,虚引用引用的对象被回收时,会将虚引用放入队列,由Reference Handler线程调用虚引用相关的方法释放直接内存。
垃圾回收算法:
标记清除:
标记-清除算法是最早出现也是最基础的垃圾收集算法算法分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象
优缺点:
- 只需要标记垃圾,然后清除,执行效率高。但是如果Java堆中包含大量对象,而且其中大部分是需要回收的,这时必须进行进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
- 内存空间的碎片问题。标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法:
所谓复制算法,就是把内存分为2块等同大小的内存空间(from和to),使用from进行内存的使用,当from部分的内存不足以分配对象而引起内存回收时,就会把存活的对象从from内存块放到to内存块中,后把from内存块中的对象全部清除,from和to交换位置。
优缺点:
- 使用这种方式可以避免出现空间碎片(内存中不连续的空间)。
- 浪费了一半的内存,降低空间的使用率。
标记整理:
标记-整理算法的标记过程与“标记-清理算法”一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
优缺点:
-
整理后的内存,避免了标记清除中的内存碎片的问题,也避免了复制算法中的内存浪费问题。
-
存在的问题就是效率问题了,移动的对象地址需要改变,会比前2者的效率低。
分代垃圾回收:
原理:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-p1fn6DML-1659427739530)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220731221244376.png)]
相关VM参数:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GAH5AC6P-1659427739533)(C:\Users\Administrator\Pictures\Saved Pictures\屏幕截图 2022-07-31 150801.png)]
垃圾回收器:
参考文献:
https://blog.csdn.net/iva_brother/article/details/87886525
垃圾回收调优:
略…
类加载:
加载:
- 先调用javac将源文件编译成.class字节码文件,然后将类的字节码载入到方法区中,内部采用c++的instanceKlass描述java类。
- 如果这个类还有父类没有加载,先加载父类。
- 加载和链接可能是交替运行
注意:
- instanceKlass这样的【元数据】是存储在方法区(1.8后的元空间),但是会有一份镜像_java_mirror是存储在堆中,可以通过这个镜像访问到instanceKlass类信息。
链接:
验证:
验证类是否符合JVM规范,安全性检查,要是.class字节码文件被篡改,则会抛出ClassFormatError
准备:
- 为static变量分配空间,设置默认值
- static变量在JDK 7之前存储于instanceKlass末尾,从JDK 7开始,存储于_java_mirror末尾
- static变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果static变量是final的基本类型,以及字符串常量,那么编译阶段就确定了,赋值在准备阶段完成
- 如果static变量是final的,但属于引用类型,那么赋值也会在初始化阶段完成
解析:
将常量池中的符号引用(只知道名字)解析为直接引用(知道类的具体内存地址)
初始化:
初始化是懒惰的:
-
main方法所在的类,总是被首先初始化
-
首次访问这个类的静态变量或静态方法时,类的静态变量是在初始化进行赋值的
-
子类的初始化,如果父类还没初始化,会引发
-
子类访问父类的静态变量,只会引发父类的初始化
-
class.forName
-
new 会导致初始化
不会导致类初始化的情况:
- 访问类的static final 静态常量(基本类型和字符串)不会触发初始化
- 类对象.class不会触发初始化
- 创建该类的数组不会出发初始化
- 类加载器的loadClass方法
- Class.forName的参数 2 为false时
类加载器:
类加载器种类:
名称 | 加载哪的类 | 说明 |
---|---|---|
Bootstrap ClassLoader | JAVA_HOME/jre/lib | 由c++实现,无法直接访问 |
Extension ClassLoader | JAVA_HOME/jre/lib/ext | 上级为Bootstrap,显示为null |
Application ClassLoader | classpath | 上级为Extension |
自定义类加载器 | 自定义 | 上级为Application |
双亲委派机制:
当一个类加载器收到了类加载的请求的时候,他不会直接去加载指定的类,而是把这个请求委托给自己的父加载器去加载。只有父加载器无法加载这个类的时候,才会由当前这个加载器来负责类的加载。
意义:
-
通过委派的方式,可以避免类的重复加载,当父加载器已经加载过某一个类时,子加载器就不会再重新加载这个类。
-
通过双亲委派的方式,还保证了安全性。防止别人篡改基本类库的API。
线程上下文类加载器:
类Thread中的getContextClassLoader()与setContextClassLoader(ClassLoader cl)分别用来获取和设置上下文类加载器。如果没有通过setContextClassLoader(ClassLoader cl)进行设置的话,线程将继承其父线程的上下文类加载器。
Java应用运行时的初始线程的上下文类加载器是应用类加载器。在线程中运行的代码可以通过该类加载器来加载类与资源。
Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。
这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径(CLASSPATH)里。SPI接口中的代码经常需要加载具体的实现类。那么问题来了,SPI的接口是Java核心库的一部分,是由**启动类加载器(Bootstrap Classloader)来加载的;SPI的实现类是由应用类加载器(Application ClassLoader)**来加载的。引导类加载器是无法找到 SPI 的实现类的,因为依照双亲委派模型,BootstrapClassloader无法委派Application ClassLoader来加载类。
而线程上下文类加载器破坏了“双亲委派模型”,可以在执行线程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器。
参考文献:
https://blog.csdn.net/yangcheng33/article/details/52631940
自定义类加载器:
为什么需要自定义类加载器?
既然JDK已经有类加载器了,为什么还需要自定义类加载器呢?大概有以下几个原因:
-
隔离加载类
模块隔离,将类加载到不同的应用程序中。比如Tomcat这类web应用服务器,内部定义了好几种类加载器,用于隔离web应用服务器上不同的应用程序。
-
扩展加载源
还可以从数据库、网络或其他终端上加载类
-
防止源码泄露
Java代码容易被编译和篡改,可以进行编译加密,类加载需要自定义还原加密字节码。
写一个自己的类加载器:
-
继承ClassLoader类。
-
重写loadClass方法(是实现双亲委派逻辑的地方,修改他会破坏双亲委派机制)
-
重写findClass方法 (自己实现类的加载,可以指定类加载的路径)
JAVA内存模型:
参考文献:
https://blog.csdn.net/javazejian/article/details/72828483
synchronized:
public class SyncCodeBlock {
public int i;
public void syncTask(){
//同步代码块
synchronized (this){
i++;
}
}
}
3: monitorenter //进入同步方法
...
15: monitorexit //退出同步方法
16: goto 24
...
21: monitorexit //退出同步方法
从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor ,重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。
volatile:
volatile在并发编程中很常见,但也容易被滥用,现在我们就进一步分析volatile关键字的语义。volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用
-
保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总数可以被其他线程立即得知。
-
禁止指令重排序优化
CAS和原子类:
CAS:
CAS的全称是Compare And Swap 即比较交换,体现一种乐观锁的思想
volatile int 共享变量 = 0;
//重试
while(true){
int 旧值 = 共享变量;
int 结果 = 旧值 + 1;
if(CompareAndSwap(旧值,结果)){
//成功 退出循环
}
}
CompareAndSwap 中旧值等于共享变量吗?等于就将结果赋值给共享变量,不等于说明共享变量在这期间被修改过,那么就退出重试。CAS配合volatile可以实现无锁并发,适合竞争不激烈的,多核cpu场景。
- 应为没有synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一。
- 但如果竞争激烈,可以想到重试必然重复发生,反而效率会受影响。
CAS底层依赖于一个Unsafe类来直接调用操作系统底层的CAS指令。
参考文献:https://blog.csdn.net/javazejian/article/details/72772470
乐观锁和悲观锁:
CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也关系,我吃亏点再重试呗。
synchronized是基于悲观锁的思想:最悲观的估计,得防着其他线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
原子操作类:
原子更新基本类型主要包括3个类:
- AtomicBoolean:原子更新布尔类型
- AtomicInteger:原子更新整型
- AtomicLong:原子更新长整型
不等于说明共享变量在这期间被修改过,那么就退出重试。CAS配合volatile可以实现无锁并发,适合竞争不激烈的,多核cpu场景。
- 应为没有synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一。
- 但如果竞争激烈,可以想到重试必然重复发生,反而效率会受影响。
CAS底层依赖于一个Unsafe类来直接调用操作系统底层的CAS指令。
参考文献:https://blog.csdn.net/javazejian/article/details/72772470
乐观锁和悲观锁:
CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也关系,我吃亏点再重试呗。
synchronized是基于悲观锁的思想:最悲观的估计,得防着其他线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
原子操作类:
原子更新基本类型主要包括3个类:
- AtomicBoolean:原子更新布尔类型
- AtomicInteger:原子更新整型
- AtomicLong:原子更新长整型