JVM体系结构
JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
可能看过上面一段简介以后还是不知道JVM是什么东西,只知道JVM是一套可供java程序运行的环境。
那么jvm在程序中到底扮演者什么地位呢?
JVM是介于java程序和操作系统之间的一个区域,由于是由C++编写,因此java又可以称为C++ - -,一个java文件编写完成后会被编译成class文件,然后经过类加载器进入运行时数据区。
如图所示,为JVM体系结构简图,由于main方法在运行时会被压入栈底,每运行完一个方法会被弹出,因此垃圾回收不可能存在在栈里,而应该存在于堆中,由于方法区为特殊的堆,因此大部分性能调优都是对于堆的性能调优。
类加载器
类加载器的主要作用是加载类对象,将class文件加载为class类,如图所示:
通过new的方式可以将class对象实例化为不同的实例化对象,实例化对象可以通过getClass的方式反射获得类对象,因此可以得出类是模板,而对象是具体的,所有的对象都是由同一个类实例化的。类的hashcode恒定,而每个对象各不相同。
测试
public class Animal {
public static void main(String[] args) {
//类是模板,对象是具体的
Animal 喜羊羊 = new Animal();
Animal 美羊羊 = new Animal();
Animal 懒羊羊 = new Animal();
System.out.println(喜羊羊.hashCode());
System.out.println(美羊羊.hashCode());
System.out.println(懒羊羊.hashCode());
Class<? extends Animal> 喜羊羊Class = 喜羊羊.getClass();
Class<? extends Animal> 美羊羊Class = 喜羊羊.getClass();
Class<? extends Animal> 懒羊羊Class = 喜羊羊.getClass();
System.out.println(喜羊羊Class.hashCode());
System.out.println(美羊羊Class.hashCode());
System.out.println(懒羊羊Class.hashCode());
}
}
输出结果
双亲委派机制
由于类加载器有很多,在程序执行的时候到底执行的是哪个加载器呢?
首先我们来看下加载器的类型
测试
public class Animal {
public static void main(String[] args) {
//类是模板,对象是具体的
Animal 喜羊羊 = new Animal();
Class<? extends Animal> aClass = 喜羊羊.getClass();
ClassLoader classLoader = aClass.getClassLoader();
System.out.println(classLoader); //应用程序加载器(AppClassLoader)
ClassLoader parent = classLoader.getParent();
System.out.println(parent); //扩展类加载器(ExtClassLoader) \Java\jre\lib\ext\
ClassLoader oldParent = parent.getParent();
System.out.println(oldParent); //null 不存在或者java获取不到 \Java\jre\lib\rt.jar
}
}
输出结果
这里可以看到加载器有
- 应用程序加载器(AppClassLoader)
- 扩展类加载器(ExtClassLoader) =>存在于\Java\jre\lib\ext\
- 根加载器(BootstrapClassLoader) =>存在于\Java\jre\lib\rt.jar
- 虚拟机自带加载器
双亲委派机制指的是类加载器(AppClassLoader)收到类加载的请求,会将这个请求向上委托给父类加载器去完成,一直向上委托。
应用程序加载器-------扩展类加载器-------根加载器-------启动类加载器
启动类加载器会检查是否可以加载这个类,如果可以加载就结束,使用当前加载器,否则抛出异常,通知子加载器进行加载,我们平时自定义的方法都是运行在应用程序加载器(AppClassLoader)上面的。
启动类加载器-------根加载器-------扩展类加载器-------应用程序加载器
当所有加载器都无法加载会通过native调用操作系统层的本地方法。
沙箱安全机制
Java安全模型的核心就是Java沙箱(sandbox),什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问,那系统资源包括什么?——CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。
java历史上的一些沙箱模型如下:
- jdk1.0
- jdk 1.1
- jdk 1.2
- jak1.6
Native关键词
凡是使用了Native关键字的都是java代码实现不了的范围,会去底层调用C语言的库。主要过程为通过本地方法栈,调用本地方法本地接口,JNI(Java Native Interface)。
JNI的作用主要是为了融合不同的编程语言为java所用,它在内存区域中专门开辟了一块标记区域Native Method Stack用来登记Native方法,在最终执行的时候加载本地方法库中的方法。
常用的有线程类中的start0()方法。
private native void start0();
PC寄存器
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。
方法区
方法区在JVM中也是一个非常重要的区域,它与堆一样,是被线程共享的区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等,但是实例对象存在堆内存中,与方法区无关。
static、final、Class、常量池等公共属性都存在方法区中。
栈
栈是一种数据结构,常用来和队列相比较。
栈是先进后出,队列是先进先出。
栈内存主管程序的运行,生命周期和线程同步。线程结束,栈内存也就释放了,不存在垃圾回收的问题。
栈里面存放的有8中基本类型、对象的引用、实例的方法。
栈的示意图如下,每执行完一个方法就会弹出一次,知道所有都弹出,线程结束。
有时会出现栈溢出的情况,是因为栈空间被占满,抽象示意图如下:
对应的代码如下:
测试
public class Test {
//栈溢出测试
public static void main(String[] args) {
new Test().a();
}
public void a(){
b();
}
public void b(){
a();
}
}
结果输出:StackOverflowError(栈溢出)
对象实例化的过程示意图如下,通对对栈里面的引用对应堆中的对象实例化实体。
堆
一个JVM中只有一个堆内存,堆内存的大小是可以调节的。
类、方法、常量、变量一般会被存放在堆中,堆中保存了所有引用类型的真实对象。
堆内存中分为三个区域:
- 新生区
- 老年区
- 永久区
如果垃圾回收没有在新生区中被回收就会进入幸存区,称为轻GC,如果多次没被回收则会进入老年区,当老年区接近满的时候则会进行深度清理,称为重GC,如图所示:
因此GC回收主要发生在新生区和老年区,当内存满值的时候,则会报错(OOM),即堆内存不够。
在JDK8以后永久存储区改名为元空间。
新生区
新生区是一个类生成、成长、消亡的地方。
新生区主要分为伊甸园区、幸存1区,幸存2区。
所有对象都是在伊甸园区new出来的。
老年区
当经过新生区还未被杀死的对象会进入老年区。
研究表明,99%的对象都是临时对象,在新生区被kill。
永久区
这个区域是常驻内存的,用来存放JDK自身携带的Class对象,以及interface元数据,存放的是java运行时的一些环境或类信息。这个区域不存在垃圾回收,关闭虚拟机就会释放这个区域的内存。
一个类家在大量第三方jar包、Tomcat部署太多应用,大量动态生成反射类不断地被加载知道内存满了就会出现OOM。
- jdk6之前:永久代,常量池存在于方法区中。
- jdk7:永久代,慢慢的退化了,常量池在堆中。(去永久代)
- jdk8:无永久代,慢慢的退化了,常量池在元空间中。
在jdk1.8下堆中的结构如图所示:
测试虚拟机试图使用的最大内存和jvm初始化总内存。
默认情况下分配的总内存是电脑内存的4/1,初始化内存为默认为总内存的64/1
测试
public class Test {
public static void main(String[] args) {
//返回虚拟机试图使用的最大内存
long max = Runtime.getRuntime().maxMemory(); //字节 1024*1024
//返回jvm的初始化总内存
long total = Runtime.getRuntime().totalMemory();
//默认情况下分配的总内存是电脑内存的4/1,初始化内存为默认为总内存的64/1
System.out.println("最大内存为:" + max/(double)1024/1024 + "MB");
System.out.println("初始化总内存为:" + total/(double)1024/1024 + "MB");
}
}
结果输出
这里可以手动设置使用内存大小,如图所示
输入 -Xms1024m -Xmx1024m -XX:+PrintGCDetails
测试
public class Test {
public static void main(String[] args) {
//返回虚拟机试图使用的最大内存
long max = Runtime.getRuntime().maxMemory(); //字节 1024*1024
//返回jvm的初始化总内存
long total = Runtime.getRuntime().totalMemory();
//默认情况下分配的总内存是电脑内存的4/1,初始化内存为默认为总内存的64/1
System.out.println("最大内存为:" + max/(double)1024/1024 + "MB");
System.out.println("初始化总内存为:" + total/(double)1024/1024 + "MB");
}
}
结果输出
堆内存调优
当出现OOM时,可采取以下方法调优
- 尝试扩大堆内存 -Xms1024m -Xmx1024m -XX:+PrintGCDetails 查看结果。
- 若仍旧出现问题,分析内存,看一下哪里出现问题。
计算可得,年轻代和老年代内存的和等于内存值,因此可以得出,元空间在逻辑上存在,在物理上不存在。
模拟堆内存溢出(OOM)
我们首先设置内存,将内存调小为1M。
修改内存 -Xms1m -Xmx1m -XX:+PrintGCDetails
测试
public class Test {
public static void main(String[] args) {
String str = "";
while (true){
str += new Random().nextInt(999999999);
}
}
}
首先轻GC,年轻代内存占满,然后重GC,老年代内存占满,最后内存溢出,OOM报错。
运行结果
使用Jprofiler工具分析OOM
常用的内存快照分析工具可以快速定位内存泄漏问题,如MAT(eclipse集成),Jprofiler等。
MAT、Jprofiler的作用:
- 分析Dump内存文件,快速定位内存泄漏。
- 获得堆中的数据。
- 获得大的对象。
安装步骤如下:
- 勾选如下设置
- 搜索插件安装
-
下载Jprofiler客户端
Jprofiler客户端下载地址 -
安装Jprofiler客户端
-
配置路径
测试:模拟OOM报错
public class OOMTest {
byte[] array = new byte[1*1024*1024];
public static void main(String[] args) {
ArrayList<OOMTest> list = new ArrayList<>();
int count = 0;
try {
while (true){
list.add(new OOMTest());
count = count+1;
}
} catch (Error e) {
System.out.println("count:"+count);
e.printStackTrace();
}
}
}
配置生成Dump文件(-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError)
在src同级目录下会生成Dump文件
查看文件,定位报错信息位置
Dump参数含义
-Xms :设置初始化内存分配大小
-Xmx:设置最大分配内存
-XX:+PrintGCDetails:打印GC垃圾回收信息
-XX:+HeapDumpOnOutOfMemoryError:OOM Dump文件生成
GC 垃圾回收机制
JVM堆里面可能存在GC的地区有以下几种
- 新生代
- 幸存区(from、to,对应前文的幸存0区和幸存1区,交替转换)
- 老年区
GC:分为轻GC(普通GC)和重GC(全局GC)
程序在运行过程中,会产生大量的内存垃圾(一些没有引用指向的内存对象都属于内存垃圾,因为这些对象已经无法访问,程序用不了它们了,对程序而言它们已经死亡),为了确保程序运行时的性能,java虚拟机在程序运行的过程中不断地进行自动的垃圾回收(GC)。
GC的常用算法
关于 JVM 的 GC 算法主要有下面四种:
1、引用计数算法(Reference counting)
每个对象在创建的时候,就给这个对象绑定一个计数器。每当有一个引用指向该对象时,计数器加一;每当有一个指向它的引用被删除时,计数器减一。这样,当没有引用指向该对象时,该对象死亡,计数器为0,这时就应该对这个对象进行垃圾回收操作。
缺点:会产生内存碎片,每个空间一个计数器浪费资源。
2、复制算法
该算法将内存平均分成两部分,然后每次只使用其中的一部分,当这部分内存满的时候,将内存中所有存活的对象复制到另一个内存中,然后将之前的内存清空,只使用这部分内存,循环下去。
优点:没有内存碎片。
缺点:浪费了内存空间,多了一半空间永远是空的(to区)。
复制算法最佳使用场景为对象存活率较低(新生区)
3、标记–清除算法(Mark-Sweep)
为每个对象存储一个标记位,记录对象的状态(活着或是死亡)。
分为两个阶段,一个是标记阶段,这个阶段内,为每个对象更新标记位,检查对象是否死亡;第二个阶段是清除阶段,该阶段对死亡的对象进行清除,执行 GC 操作。
优点:不需要额外的空间。
缺点:两次扫描严重浪费时间,会产生内存碎片。
4、标记–整理算法
标记-整理法是标记-清除法的一个改进版。同样,在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是将所有存活的对象整理一下,放到另一处空间,然后把剩下的所有对象全部清除。这样就达到了标记-整理的目的。
优点:解决了内存碎片问题。
缺点:整理需要再次消耗一次资源,浪费时间。
GC的常用算法总结
内存效率(时间复杂度):复制算法>标记清除算法>标记整理算法
内存整齐度:复制算法=标记整理算法>标记清除算法
内存利用率:标记整理算法=标记清除算法>复制算法
没有最好的算法,只有最合适的算法,因此GC又被称为分代收集算法。
年轻代:存活率低,一般使用复制算法。
老年代:区域大,存活率高,一般使用标记清除(内存碎片较少时)+标记压缩(内存碎片较多时)混合使用。
JMM
JMM(Java Memory Model的缩写)是一种java内存模型。它类似于缓存一致性协议,用于定义数据读写的规则。
JMM定义了线程工作内存和主内存的一种抽象关系,线程中的共享对象存在于主内存中,每个线程都有一块私有的本地内存。
解决共享对象可见性问题,即线程中修改数据立马同步到主线程中,其它线程复制时可以得到最新的数据:volilate和synchronize关键词。
JMM数据同步模型如下图。
volatile关键词
volatile是可以保持可见性,不能保证原子性,由于内存屏障,可以保证避免指令重排的现象产生!
可见性
两个线程,同时对单一对象进行操作时,均为从对象中复制一份,然后对复制的对象进行操作,然后合并。
可见性指的是当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,保证修改的数据为最新的数据。
可见性测试
public class VisibilityDemo {
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while(num == 0){
}
}).start();
TimeUnit.SECONDS.sleep(1);
num = 1;
System.out.println(num);
}
}
输出结果
此时线程陷入死循环,无法感知到main线程已经对数据进行了修改。
public class VisibilityDemo {
private volatile static int num = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while(num == 0){
}
}).start();
TimeUnit.SECONDS.sleep(1);
num = 1;
System.out.println(num);
}
}
输出结果
此时可以感知到main线程的变化,退出循环。
非原子性
原子性测试
public class AtomicityDemo {
private volatile static int num = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
for (int i = 0; i < 50000; i++) {
num++;
}
System.out.println("T1完成");
},"T1").start();
new Thread(()->{
for (int i = 0; i < 50000; i++) {
num++;
}
System.out.println("T2完成");
},"T2").start();
TimeUnit.SECONDS.sleep(1);
System.out.println(num);
}
}
输出结果
Num++不是原子操作,因为其可以分为:读取Num的值,将Num的值+1,写入最新的Num的值。
对于Num++;操作,线程1和线程2都执行一次,最后输出Num的值可能是:1或者2。
输出结果1的解释:当线程1执行Num++;语句时,先是读入Num的值为0,倘若此时让出CPU执行权,线程获得执行,线程2会重新从主内存中,读入Num的值还是0,然后线程2执行+1操作,最后把Num=1刷新到主内存中; 线程2执行完后,线程1已经开始执行,但之前已经读取的Num的值0,所以它还是在0的基础上执行+1操作,也就是还是等于1,并刷新到主内存中。所以最终的结果是1。
解决方案:使用CAS,通过自旋锁解决原子性问题
public class AtomicityDemo {
private volatile static int num = 0;
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicInteger = new AtomicInteger(0);
new Thread(()->{
for (int i = 0; i < 50000; i++) {
// num++;
atomicInteger.getAndIncrement();
}
System.out.println("T1完成");
},"T1").start();
new Thread(()->{
for (int i = 0; i < 50000; i++) {
// num++;
atomicInteger.getAndIncrement();
}
System.out.println("T2完成");
},"T2").start();
TimeUnit.SECONDS.sleep(1);
// System.out.println(num);
System.out.println(atomicInteger.get());
}
}
输出结果
CAS应用场景
public class CASDemo {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(2020);
// int andIncrement = atomicInteger.getAndIncrement();
// public final boolean compareAndSet(int expect, int update)
// 如果期望的值达到了,那么就更新,否则,就不更新,CAS 是 CPU 的并发原型
System.out.println(atomicInteger.compareAndSet(2020, 2021));
System.out.println(atomicInteger.get());
System.out.println(atomicInteger.compareAndSet(2021, 2020));
System.out.println(atomicInteger.get());
System.out.println(atomicInteger.compareAndSet(2020, 6666));
System.out.println(atomicInteger.get());
}
}
输出结果
常见示例:自旋锁实现
CAS是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步
当第一个线程进入myLock时while条件为false,可以加锁,当第二个线程进入时while条件为true,进入循环,直到myUnLock执行结束,第二个线程跳出while循环,加锁成功。
自旋锁是指对一个内容无限循环,当达成条件的时候对其加锁,底层使用的是CAS。
自定义自旋锁
public class SpinLock {
AtomicReference<Thread> atomicReference = new AtomicReference<>();
public void myLock(){
Thread thread = Thread.currentThread();
while (!atomicReference.compareAndSet(null,thread)){
}
System.out.println(Thread.currentThread().getName() + "==> myLock");
}
public void myUnLock(){
Thread thread = Thread.currentThread();
atomicReference.compareAndSet(thread,null);
System.out.println(Thread.currentThread().getName() + "==> myUnLock");
}
}
自旋锁测试
public class SpinLockTest {
public static void main(String[] args) throws InterruptedException {
SpinLock lock = new SpinLock();
new Thread(()->{
lock.myLock();
try {
TimeUnit.SECONDS.sleep(5);
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.myUnLock();
}
},"T1").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{
lock.myLock();
try {
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.myUnLock();
}
},"T2").start();
}
}
输出结果
线程T1占用时,线程T2会一直在循环中无法出来。只有当线程T1解锁,线程T2才会跳出循环,最终才会触发T2解锁。
常用示例:利用CAS解决ABA问题
CAS是java利用unsafe类通过对计算机底层的调用来进行数据的操作。底层实现为自旋锁。
CAS对数据修改时,可能会出现对数据修改两次,修改后值与之前相同的情况,因此会认定为未修改,此类问题被称为ABA问题,为了解决此类问题可以使用乐观锁,对每次记录新增一个记录,每次修改记录+1。
public class ABADemo {
public static void main(String[] args) {
AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(1,1);
new Thread(()->{
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
int stamp = atomicStampedReference.getStamp(); //获得版本号
System.out.println("a1 =>"+atomicStampedReference.getStamp());
System.out.println(atomicStampedReference.compareAndSet(1, 2, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1));
System.out.println("a2 =>"+atomicStampedReference.getStamp());
System.out.println(atomicStampedReference.compareAndSet(2, 1, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1));
System.out.println("a3 =>"+atomicStampedReference.getStamp());
},"a").start();
new Thread(()->{
int stamp = atomicStampedReference.getStamp(); //获得版本号
System.out.println("b1 =>"+stamp);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(atomicStampedReference.compareAndSet(1, 6, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1));
System.out.println("b2 =>"+atomicStampedReference.getStamp());
},"b").start();
}
}
结果分析
避免指令重排
指令重排指的是,一段代码写出来的顺序可能为1=>2=>3=>4=>5,但是经过编译器以后的顺序并不一定严格按照这种顺序,可能在不影响结果的情况下改变为1=>3=>2=>4=>5。这种情况在单线程的情况下不会有任何问题,但是在多线程的情况下则会出现问题。而volatile关键词可以增加一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障。
常用示例:DCL懒汉式(双重检测锁模式)
//懒汉式单例模式
public class LazyMan {
private LazyMan(){
}
private volatile static LazyMan lazyMan;
//双重检测锁模式的懒汉式单例 DCL懒汉式
public static LazyMan getInstance(){
if (lazyMan == null){
synchronized (LazyMan.class){
if (lazyMan == null){
lazyMan = new LazyMan(); //不是一个原子性操作
}
}
}
return lazyMan;
}
}
此时当第一次创建对象时会进行加锁,但是由于指令重排可能会造成以下情况,所以需要对对象加上volatile关键词防止指令重排
/**
* 1、分配内存空间
* 2、执行构造方法,初始化对象
* 3、把这个对象指向这个空间
*
* 此时程序执行顺序可能为
* 1->2->3
* 1->3->2
* 若为1->3->2则可能发生以下情境
*
* A线程执行完1->3时,B线程执行,此时对象有指向的内存空间,但是并未初始化
* 当B进行判断时 lazyMan == null 为 false,则会直接返回未初始化的对象。
* 因此在声明对象时必须加上 volatile 关键词来防止指令重排
*/
总结
Synchronized和Volatile的比较
1)Synchronized保证内存可见性和操作的原子性
2)Volatile只能保证内存可见性
3)Volatile不需要加锁,比Synchronized更轻量级,并不会阻塞线程(volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。)
4)volatile标记的变量不会被编译器优化,而synchronized标记的变量可以被编译器优化(如编译器重排序的优化).
5)volatile是变量修饰符,仅能用于变量,而synchronized是一个方法或块的修饰符。
volatile本质是在告诉JVM当前变量在寄存器中的值是不确定的,使用前,需要先从主存中读取,因此可以实现可见性。而对n=n+1,n++等操作时,volatile关键字将失效,不能起到像synchronized一样的线程同步(原子性)的效果。