注意区分Java内存模型(Java Memory Model,简称JMM)与Jvm内存结构,前者与多线程相关,后者与JVM内部存储相关。本文会对两者进行简单介绍。
一、JAVA内存模型(JMM)
1. 概念
说来话长,由于在不同硬件厂商和不同操作系统之间内存访问有一定差异,所以会使得相同代码在不同平台上运行结果可能不一致。为了使java程序在各种平台下达成一致的运行效果,所以JMM屏蔽掉各种硬件和操作系统的内存访问差异。
JMM规定除局部变和方法参数以外的所有变量都存储在主内存中。从线程角度,其基本工作方式是:工作内存保存了线程用到的变量和主内存的副本,只能修改工作内存的值然后刷回主存,不能直接读写主内存中的变量。
一般问到Java内存模型都是想问多线程,Java并发相关的问题。
2. 内存屏障
现代计算机CPU多为多核,每核有自己的高速缓存,易导致内存数据读写不一致,产生指令乱序和不可见性问题。内存屏障确保指令顺序执行和内存操作的全局可见性,防止重排序,并即时更新和展示内存数据给其他CPU核,解决读写延迟问题。读屏障清除缓存,确保后续读取最新数据;写屏障刷新缓存数据到内存,使其对其他核可见。JMM针对读load写store提出了针对这两个操作的四种组合来覆盖度读写的所有情况。
LoadLoad 屏障:确保所有之前的读操作都完成后再执行之后的读操作。
StoreStore 屏障:确保所有之前的写操作都完成后再执行之后的写操作。
LoadStore 屏障:确保所有之前的读操作都完成后再执行之后的写操作。
StoreLoad 屏障:确保所有之前的写操作都完成并对其他处理器可见后,再执行之后的读操作。
3.原子性 可见性 有序性
3.1原子性
原子性指的是一个操作是不可分割,不可中断的,一个线程在执行时不会被其他线程干扰。i++不是原子操作,因为它是先读取到i,再加1,是两步操作不保证原子性。代表性的是synchronized关键字,该关键字修饰的方法或代码块可保证原子性。
3.2 可见性
可见性是指一个线程修改了某个变量的值,这个改动能立即被其他线程感知。volatile关键字可以保证变量的可见性,当变量被该关键字修饰时,这个变量的改动会被立即刷新到内存,其他线程会在主内存中读取该变量的新值。final和synchronized也可保证可见性。
<happens-before>
happens-before是指前一个操作的结果对后续操作是可见的,并不是指前面一个操作一定发生在后面一个操作的前面。在不改变程序执行结果的前提下,编译器和处理器可以自由优化程序执行顺序,因为程序员只关心程序执行的语义是否正确。
3.3 有序性
在Java中,volatile和synchronized都能维护多线程操作的有序性。volatile通过内存屏障禁止指令重排,而synchronized则通过锁定机制,确保同一时间只有一个线程可以执行被其保护的代码块,从而实现有序性。
4. synchronezid volatile关键字
4.1 synchronezid
4.1.1 基本使用
synchronezid可以修饰方法、类和代码块。修饰实例方法锁住的是对象,即对象锁;修饰静态方法锁住的是类,即类锁;修饰代码块,指定加锁对象,对给定对象加锁,也是对象锁。
对象锁可以有多个,new几个对象就有几个对象锁,但是类锁只有一把。
//修饰方法
public synchronized void add(){
i++;
}
//修饰类
public static synchronized void add(){
i++;
}
//修饰代码块
public void add() {
synchronized (this) {
i++;
}
}
4.1.2 底层原理
查看上面代码的字节码
//修饰代码块
public void add();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter // synchronized关键字的入口
4: getstatic #2 // Field i:I
7: iconst_1
8: iadd
9: putstatic #2 // Field i:I
12: aload_1
13: monitorexit // synchronized关键字的出口
14: goto 22
17: astore_2
18: aload_1
19: monitorexit // synchronized关键字的出口
20: aload_2
21: athrow
22: return
通过字节码文件看出synchronized修饰代码块使用monitorenter和monitorexit指令。monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,设置计数器值为1。执行monitorexit指令,将释放 monitor(锁)并设置计数器值为0。monitor存储于对象头信息中,每个对象都存在一个monitor与之关联。
//修饰方法
public synchronized void add();
descriptor: ()V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
LineNumberTable:
line 5: 0
line 6: 10
synchronized修饰实例方法对应的字节码没有 monitorenter和monitorexit ,却额外多了 ACC_SYNCHRONIZED。因为整个方法都是同步代码,因此就不需要标记同步代码的入口和出口了。当线程线程执行到这个方法时会判断是否有这个ACC_SYNCHRONIZED标志,如果有的话则会尝试获取monitor对象锁。如果有异常发生,线程自动释放锁。
4.2 volatile
能保证变量的可见性,禁止指令重排序。
可见性原理
每个线程都有一个Jvm栈,栈内保存线程运行时的变量信息。当线程访问对象的属性时,首先会找到堆内对象存的变量值,再将其保存为栈内的一个副本,之后会直接修改副本中属性的值。修改完后不会立即将修改的值更新到堆中,这就导致某些线程读取到的还是旧值。volatile就是当副本中属性的值被修改后保证其能立即同步到堆中,从而其他线程读取到该值,也是新的值。
禁止指令重排序原理
通过插入内存屏障禁止指令重排序。插入内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。volatile写操作的前面插入一个StoreStore屏障,后面插入一个SotreLoad屏障。
<volatile不能保证线程安全,可见性不能保证原子操作>
二、JVM内存结构
1. 组成
JVM的内存划分为5部分,Java栈,本地方法栈,堆,程序计数器和方法区。
1-JAVA栈 即虚拟机栈
根据线程创建而创建,所以每个线程都有一个虚拟机栈。虚拟机栈存储的是栈帧,每个栈帧对应一个方法,且都有自己的局部变量表,操作数栈、动态链接和返回地址等。
局部变量表存放了编译器可知的各种基本数据类型(int、short、byte、char、double、float、long、boolean)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一跳字节码指令的地址)。
JVM规范中,Java虚拟机栈部分规定了两种异常:StackOverflowError
发生在递归调用过深时,由于程序设计的错误,如递归无终止条件;OutOfMemoryError
发生在JVM内存不足或设置过小,导致无法为新线程分配栈空间。
2-本地方法栈
java虚拟机栈为虚拟机执行Java方法服务。本地方法栈则为虚拟机使用的native方法服务。native方法是用C语言实现的底层方法。
3-堆
生命周期与进程相同,被所有线程所共享的内存区域。该区域存放的是对象实例。堆同时也是GC的主要区域。通常情况下,它占用的空间是所有内存区域中最大的,但如果无节制地创建大量对象,也容易消耗完所有的空间;堆的内存空间既可以固定大小,也可运行时动态地调整,通过参数-Xms设定初始值、-Xmx设定最大值。
4-程序计数器
它是一块极小的内存空间。记录了当前线程执行到的字节码行号。每个线程都有自己的程序计数器,互不影响。native方法计数器为空。
5-方法区
被线程共享,储存已被虚拟机加载的类信息、常量、静态变量、jit编译后的代码等数据。
Java源代码编译成Java Class文件后通过类加载器ClassLoader加载到JVM中
类存放在方法区中
类创建的对象存放在堆中
堆中对象的调用方法时会使用到虚拟机栈,本地方法栈,程序计数器
方法执行时每行代码由解释器逐行执行
热点代码由JIT编译器即时编译
垃圾回收机制回收堆中资源
和操作系统打交道需要调用本地方法接口
2. 类加载过程
2.1 加载
加载指的是将类的class文件读入到内存中,并为之创建一个java.lang.Class对象。 类加载阶段可以使用系统提供的类加载器(ClassLoader)来完成,也可以使用用户自定义的类加载器(继承ClassLoader)完成。
2.2 连接
2.2.1 验证
验证被加载的类文件符合JVM规范,保证载入的类不会危害JVM。
文件格式验证→元数据验证→字节码验证→符号引用验证
2.2.1.1 文件格式验证
2.2.1.2 元数据验证
2.2.1.3 字节码验证
2.2.1.4 符号引用验证
2.2.2 准备
在方法区中为类变量(被static修饰的变量)分配内存,并将其初始化为默认值。
对于 public static int value = 123;变量value在准备阶段过后的初始值为0而不是123,初始化时才会将value值赋为123。 如果类字段的字段属性表中存在ConnstantValue属性,那在准备阶段value就会被初始化为ConstantValue属性所指定的值,如:public static final int value = 123;编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
2.2.3 解析
将类中的符号引用转化为直接引用。编译的时候每个java类都会被编译成一个class文件,但在编译的时候虚拟机并不知道所引用类的地址,所以就用符号引用来代替。符号引用以一组符号来描述所引用的目标。直接引用可以是直接指向目标的指针。
2.3 初始化
执行类的初始化方法(<clinit>()
方法)来初始化类的静态变量(程序设置值)和执行静态代码块。
2.4 使用
2.5 卸载
3. 类加载机制
1、全盘负责 类加载器加载某个类时,该类所依赖和引用其它的类也由该类加载器载入。
2、双亲委派 先让父加载器加载该类,父加载器无法加载时才考虑自己加载。 如果父加载器还存在其父加载器,则进一步向上委托,如果父类加载器可以完成父加载任务,就成功返回,如果父加载器无法完成加载任务,子加载器才会尝试自己去加载,可避免重复加载。
3、缓存机制 缓存机制保证所有加载过的class都会被缓存,当程序中需要某个类时,先从缓存区中搜索,如果不存在,才会读取该类对应的二进制数据,并将其转换成class对象,存入缓存区中。 这就是为什么修改了class后,必须重启JVM,程序所做的修改才会生效的原因。
4. 反射
Java 的反射机制是指在运行状态中,对于任意一个类都能够知道这个类所有的属性和方法; 并且对于任意一个对象,都能够调用它的任意一个方法;这种动态获取信息以及动态调用对象方法的功能成为Java语言的反射机制。
4.1 实例化方式
Date date=new Date();
//方式1
Class<?> date =Class.forName("java.util.Date");
//方式2
System.out.println(date.getClass());
//方式3
System.out.println(Date.class);
4.2 实例化对象
//通过反射机制,获取Class,通过Class来实例化对象
Class<?> cl=Class.forName("java.util.Date");
//newInstance() 这个方法会调用Date这个类的无参数构造方法,完成对象的创建。
// 重点是:newInstance()调用的是无参构造,必须保证无参构造是存在的!
Object object=cl.newInstance();
5.GC
5.1 判断对象可回收的方法
5.1.1 引用计数算法
一种已经被淘汰了的算法。回收方式是看对象是否被引用。如果引用则计数器加一,如果对象没有引用,则减一。他被淘汰的原因就是有循环引用问题,因为循环引用时,对象引用计数永远不会为0。这就导致这些对象无法进行回收。
5.1.2 根可达算法
目前使用的多是根可达算法。该算法创建一系列GC根,然后判断对象是否可以到达这个根。如果对象能够到达这个根,则说明对象仍然在进行使用,则不可进行回收。反之,认为对象是垃圾。
什么是GC根?<两栈两方法>
虚拟机栈中引用的方法,即栈帧中的本地变量表。
本地方法栈中引用的对象,即native方法。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
a, b 对象可回收,就一定会被回收吗?
并不是,对象的 finalize 方法给了对象一次垂死挣扎的机会,当对象不可达(可回收)时,当发生GC时,会先判断对象是否执行了 finalize 方法,如果未执行,则会先执行 finalize 方法,我们可以在此方法里将当前对象与 GC Roots 关联,这样执行 finalize 方法之后,GC 会再次判断对象是否可达,如果不可达,则会被回收,如果可达,则不回收!
注意: finalize 方法只会被执行一次,如果第一次执行 finalize 方法此对象变成了可达确实不会回收,但如果对象再次被 GC,则会忽略 finalize 方法,对象会被回收!这一点切记!
5.2 常见回收算法
5.2.1 标记清除算法
该算法首先标记出所有需要回收的对象。标记完成后对标记对象进行统一回收。但这种算法可能有内存碎片问题,虽然有内存碎片,但它是性能非常快的一种回收方式。
5.2.2 标记整理算法
这种算法为了优化标记清除算法导致的内存碎片问题,会在标记完成后将所有不回收对象向内存一端移动,然后对区域外的对象统一进行回收。这样就不会产生内存碎片,但正是这个移动的过程会导致效率不如标记清除算法。
5.2.3 复制算法
也是为了解决标记清除算法的碎片问题。过程是将内存按照容量分为两块。每次只使用其中一块空间,当进行回收时将该块不可回收对象复制到另一块。当另一块需要回收时,再把另一块不可回收对象复制到这一块。这种算法虽然也解决了内存碎片问题,但由于将内存分为两块所以可用内存相当于是减少了。
5.2.4 分代算法
把一块空间分为多个部分,每个部分可以使用不同的算法,更加灵活。一般是将堆分为新生代和老年代两部分。
新生代又被分为1个Eden区和2个Survivor区。该区域采用复制算法进行回收。每次使用Eden区和其中一块Survivor,回收时将Eden和当前Survivor中还存活的对象一次性复制到另一块Survivor空间,最后清理Eden和使用过的Survivor。这个过程叫minorGC。
如果对象没有变得不可达,且从新生代存活下来,会被拷贝到老年代。当老年代对象满就会触发FullGC。该区域采用的是标记清除算法或者标记整理算法。
5.3 引用类型
5.3.1 强引用
当我们使用new创建对象时,被创建的对象就是强引用。只要强引用存在,垃圾回收器将永远不会回收被引用的对象。如果想中断强引用与对象之间的联系,可以显示的将强引用赋值为null。
5.3.2 软引用
只有在gc且内存不足时,系统会回收软引用对象。
5.3.3 弱引用
只要 JVM 开始进行垃圾回收,那些被弱引用关联的对象都会被回收。
5.3.4 虚引用
如果一个对象只具有虚引用,那么它就和没有任何引用一样,随时会被JVM当作垃圾进行GC。
Stop-the-World,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应, 有点像卡死的感觉,这个停顿称为STW。
5.4 垃圾回收器
以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。没有最完美的收集器,不同收集器之间各有优缺点。
单线程与多线程: 单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程;
串行与并行: 串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序STW;并行指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
5.4.1 Serial GC
JVM参数 -XX:+UserSerialGC
Serial 翻译为串行,也就是说它以串行的方式执行。Serial收集器采用复制算法、串行回收和“stop-the-world”机制的方式执行内存回收。 它是单线程的收集器,只会使用一个线程进行垃圾收集工作。但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。 它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。它是 Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。
5.4.2 ParNew GC
Par是Parallel的缩写,New只能处理的是新生代。ParNew收集器除了采用并行回收的方式执行内存回收外,与Serial垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、Stop-the-World机制。ParNew是很多JVM运行在server模式下新生代的默认垃圾收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。
5.4.3 Parallel Scavenge GC
HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样也采用了复制算法、并行回收和Stop-the-world机制。
① 和ParNew收集器不同,Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。 高吞吐量可以高效率地利用 CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
② 自适应调节策略也是Parallel Scavenge与ParNew一个重要区别。 通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手动指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。 在Java8中,新生代默认是此垃圾收集器。
5.4.4 Serial Old GC
Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。 SerialOld收集器采用标记-整理算法、串行回收和“stop-the-world”机制的方式执行内存回收。 如果用在 Server 模式下,它有两大用途:在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
5.4.5 Parallel Old GC
Parallel Scavenge收集器的老年代版本,使用多线程和标记整理算法进行回收。在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
5.4.6 CMS(Concurrent Mark Sweep)
\
JAVA14中 该收集器被删除
5.4.7 G1(Garbage First)
一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
①初始标记②并发标记
③最终标记
为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
④ 筛选回收
首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
G1特点:
空间整合: 整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
可预测的停顿: 能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
5.4.8 ZGC
只能说ZGC的出现使JVM调优不存在了🤣(详细待施工)
6. JVM调优
既然ZGC的出现使得JVM调优不存在了,那么就参考下其他大佬的博客吧。包含JVM调优,死锁及OOM问题定位。JVM篇之调优_jvm调优-CSDN博客