二. JVM虚拟机
以下内容整理自《深入理解JAVA虚拟机》
“程序预储存,计算机自动执行” — 冯·诺依曼
1. 请简要阐述Java内存模型
并发编程有三个概念,原子性,可见性,有序性。JAVA支持多线程,所以需要提供这三者的保证。
特性 | 说明 |
---|---|
原子性 | 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。 |
可见性 | 当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。 |
有序性 | 即程序执行的顺序按照代码的先后顺序执行。 |
Java内存模型只保证了基本类型的基本读取和赋值是原子性操作(long,double 64位除外),而更大范围的操作则通过sychronized,lock,Atomic操作类来保证。
(jdk1.8添加了long和double的原子操作类)
Java的可见性由volatile来保证。线程对于变量的操作只能在自己的工作内存中进行,而不能直接对主内存操作,volatile保证每次变量被写入内存时都会更新主内存的变量并废弃其他线程的缓存。
- Java的指令重排虽然不会影响单线程的执行顺序,但是在多线程下无效。Java的有序性主要依靠“volatile” 禁止上下文指令重排和 “habbens-before原则”来保证。
- 代码按书写顺序运行
- 先解锁才能加锁
- 变量写优先于读
- A>B>C,A>C
- Tread start()优先,
- Tread interrupt()优先中断
- Thread终止检测最后(join,isAlive)
- 对象初始化先于finalize()清理方法,(GC前调用)
2. 请简要阐述JVM内存模型
Java虚拟机在执行Java程序的过程中会把它管理的内存划分成若干个不同的数据区域:
程序计数器,虚拟机栈,本地方法栈,堆,方法区/元空间
2.1 程序计数器/指令计数器
程序计数器是一个特别的寄存器,用来决定程序执行的流向,大小几乎可以忽略不计。
同时,程序计数器是唯一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域(OOM)
线程是CPU 调度的最小单元,JVM的多线程是通过切换线程和时间片来实现的。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
2.2 虚拟机栈
栈也是线程私有的,先进后出,每个方法被执行的时候都会同时创建一个栈帧,处于栈顶的栈桢叫当前栈桢。一般将栈帧内存的大小称为宽度,而栈帧的数量被称为虚拟机栈的深度。
栈帧的大小由虚拟机编译程序代码的时候就决定了,栈桢的宽度和深度成反比。
2.2.1 局部变量表
局部变量表顾名思义用来储存方法的局部变量,以变量槽Slot为最小单位,对于64位的数据类型会为其分配两个连续的Slot储存。
如果执行非static方法,局部变量标的第0个Slot 会储存对象的引用,可以通过this来引用。
2.2.2 操作数栈
操作数栈也常被称为操作栈,通过入栈和出栈操作指令,进行算术运算
2.2.3 动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用
2.2.4 方法返回地址
方法的返回方式有两种,第一个就是return,还有一个是异常
栈桢会保存程序计数器的值用来返回方法,而在方法出现异常使用异常处理器的值
2.2.5 其他信息
栈帧中还可能保存一些虚拟机的信息,通常把动态链接,方法返回地址,其他信息合称为栈桢信息
2.3 本地方法栈
java语言本身不能对操作系统底层进行访问,本地方法栈中存放的就是调用其他语言的函数,由native关键字修饰。
2.4 堆
Java堆是所有线程共享的一块内存区域,用来存放对象实例,在虚拟机启动时创建。
2.4.1 对象结构
Java对象保存在内存中时,由以下三部分组成:对象头,实例数据,对齐填充字节
对象头
java的对象头由以下三部分组成:
头信息 Mark Word
Mark Word记录了对象和锁有关的信息,用来表现当前锁的状态,比如无锁和偏向锁的标志位为01。JDK1.6以后java在处理同步锁的时候有锁升级的概念,如果一个锁被重复请求,会由偏向锁升级到轻量级锁,最终升级到重量级锁。
类的指针
指向类对象存放地址的指针
数组长度
只有数组对象有
实例数据
对象的实例数据就是在java代码中能看到的属性和他们的值。
对齐填充字节
因为JVM要求java的对象占的内存大小应该是8bit的倍数,所以后面有几个字节用于把对象的大小补齐至8bit的倍数,没有特别的功能。
2.4.2 GC垃圾回收
2.4.2.1 垃圾回收算法
Java堆是垃圾收集器管理的主要区域,垃圾回收的算法有以下几种
- 引用计数法
给对象中添加一个引用计数器,计数器为0的对象会被回收,缺点是相互循环引用的问题 - 可达性分析算法
这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可达的,即不可用。
可以最为GC Roots 对象有 : Native方法引用的对象,类静态属性引用的对象,方法区中常量引用的对象 - 复制清除算法
IBM公司的专门研究表明,新生代中的对象98%是“朝生昔死”的,复制清除算法就发生在新生代。他将新生代划分成survivor前后幸存区和eden伊甸园区,比例为1:1:8。对象储存在eden中,young gc之后储存在from survivor区,再次触发后将幸存对象复制到to survivor区并清空另外两区,如此循环往复,优点是效率高。 - 标记清除算法
在对象存活率较高时使用复制算法会浪费大量内存,而young gc 16次还存活的,或超过survivor区大小的对象,会交由老生代回收。
标记清除算法分为“标记”和“清除”阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。他的缺点在于效率慢和内存碎片 - 标记-整理算法
在“标记-清除”的基础上,添加了内存整理,让所有存活的对象向内存的一段移动。 - 分代收集算法
根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
2.4.2.2 垃圾回收器
- Serial串行收集器(复制清除算法) 新生代
- SerialOld串行收集器(标记-整理) 老生代
- ParNew并行收集器(标记整理算法) 新生代
- ParallelOld并行收集器(标记-整理算法)老生代
jdk1.8以前使用的CMS并发标记收集器(标记-清除)老生代
以获取最短回收停顿时间为目标的收集器,虽然不会STW,但是占用CPU资源,无法处理浮动垃圾(默认92%会触发Full GC),产生内存碎片
jdk1.8后出现的G1收集器
G1收集器将内存分成不同的Region区域,每个Region被标记了E(eden)、S(survivor)、O(old)和H(humongous超大对象)。G1跟踪各个Region里的垃圾堆价值大小,在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region回收(这也就是它的名字Garbage-First的由来)。内存优先储存在E,触发young gc后储存到S或O,在O越来越满后触发mixed gc根据回收成本回收O,类似CMS。最后如果触发full gc就会触发串行收集器导致STW。
jdk1.11出现的ZGC,不作太多了解
2.5 方法区/元空间
方法区(Method Area)又叫永生代,与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
JDK1.8后用元空间代替了永生代,区别是元空间并不在虚拟机中,而是使用本地内存。
java的静态变量一旦定义,将一直存在于整个系统运行的整个过程,java垃圾回收机制,永远不会回收它占用的内存,定义过多必然造成大量占用java虚拟机的内存,影响系统的数据处理过程,甚者造成内存溢出。
3. java的引用策略
JDK1.2以后,Java对引用的感念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)
3.1 强引用(StrongReference)
大部分对象,类似“Object A = new Object()”。内存不足时会抛出OOM异常而不会回收
3.2 软引用(SoftReference)
如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存,可以实现内存敏感的高速缓存,维护系统的运行安全,防止OOM对等。
3.3 弱引用(WeakReference)
只具有弱引用的对象拥有更短暂的生命周期。GC一旦发现只具有弱引用的对象,会直接回收。
3.4 虚引用(PhantomReference)
虚引用并不会决定对象的生命周期,它就和没有任何引用一样,在任何时候都可能被垃圾回收,虚引用主要用来跟踪对象被垃圾回收的活动。
4. Java的锁机制
java是支持多线程的,为了保证操作的的原子性,引入了锁,多个线程操作共享变量并竞争同一个锁,只有持有锁的线程才能操作变量,也即锁竞争
4.1 Sychronized 修饰符
Synchronized的四种用法:
- 修饰一个Class对象(synchronized(ClassName.class) {})
- 修饰一个对象(synchronized(this) {})
- 修饰方法(synchronized void method() {})
- 修饰静态方法(synchronized static void method() {})
Synchronized修饰非静态方法和代码块时取得的是对象锁,每个对象对应一个对象锁
Synchronized修饰静态方法和代码块或者Class对象时取得的是类锁,对象共用一个锁
Synchronized的锁仅针对被Synchronized修饰方法和代码块,对其他区域不起线程同步的作用
4.2 锁的分类
Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。
4.2.1 乐观锁 VS 悲观锁
对于同一个数据的并发操作
悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
Java中,synchronized关键字和Lock的实现类都是悲观锁。
而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。
乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
4.2.2 自旋锁 VS 适应性自旋锁
java的锁竞争会引起线程上下文的切换,而上下文切换会引起损耗。在许多场景中,由于代码简单,同步资源的锁定时间很短,那么切换损耗可能会大于程序运行时间,得不偿失。
如果有多个处理器能让多个线程同时运行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁,如果释放了就直接获取锁执行线程,这就是自旋锁。
自旋锁虽然避免了线程切换的开销,但是他要占据处理器的时间,如果线程持有锁的时间太长就会白白浪费资源,通常自旋次数为10次。
自旋锁的实现原理同样也是CAS,在JDK1.6之后引入了适应性自旋锁,如果多次自旋成功,那么会有更多的自旋次数,反之多次失败的话会直接跳过自旋进入阻塞。
4.2.3 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
JDK1.6之后引入了锁升级的概念,如果一个锁被多线程重复获取,会逐步使用更加保守的锁竞争策略。
- 无锁(标志位01 ,偏向锁指针0 )
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其他线程一直常识直到成功。无锁的特点就是高性能 - 偏向锁(标志位01 ,偏向锁指针1 )
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。具体实现是当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。 - 轻量级锁(标志位00 )
是指偏向锁被另外的线程所访问的时候,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向锁空间的指针,并将锁空间内复制的Mark Word里的owner指针指向锁空间,如果成功这个线程就获得了锁,此时等待的线程都会进入自旋。 - 重量级锁(标志位10 )
同理,重量级锁的区别是MarkWord中的owner指针指向了重量级锁的指针,此时等待的线程都会进入阻塞
GC的标志位为11
综上可得:
- 偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。
- 轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程切换影响性能。
- 重量级锁是将除了拥有锁的线程以外的线程都阻塞。
| 锁状态
| —- | —- | —- | —- | —- | —- | | | 23bit| 25bit
| | 4bit
| 1bit
| 2bit
|
| 无锁| 2bit
| | 是否偏向锁
| 锁标志位
|
| 偏向锁| 对象的HashCode
| | 分代年龄
| 0
| 01
|
| 轻量级锁| 线程ID
| Epoch
| 分代年龄
| 1
| 01
|
| 重量级锁| 指向栈中锁记录的指针
| | | | 00
|
| GC标记| 指向重量级锁的指针
| | | | 10
|
| 空
| | | | 11
|
4.2.4 公平锁 VS 非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
两者的区别主要是:
公平锁的进程在获取锁的使用会先判断队首有没有进程,如果有则进入等待队列,在源码里通过&&的逻辑短路效果实现
4.2.5 可重入锁 VS 非可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
重入锁维护了一个线程持有者和一个计数器用来记录锁被获取的次数,在被同一个线程访问时无需获取锁,而是给计数器加1,ReentrantLock和Synchronized都是重入锁
synchronized的可重入性:
对于同一个对象,或者父类的synchronized区域,都是可重入的。
ReentrantLock 重入锁:
Lock是锁的抽象,java允许把锁定的实现作为 Java 类。ReentrantLock 类实现了 Lock,祥比synchronized它添加了轮询锁、定时锁等候和可中断锁等
两者的区别:
- 实现:Synchronized基与JVM实现,而ReentrantLock 基于JDK来实现
- 性能:JDK1.6优化后,Synchronized性能已经与ReentrantLock差不多了
- 功能:ReentrantLock支持更加灵活的锁功能
- ReentrantLock 默认为非公平锁,并支持指定为公平锁
- ReentrantLock 继承了Lock类,可以使用Condition条件来进行更精细的锁操作
- ReentrantLock 提供了中断等待锁的线程的机制,可以中断持有线程的锁,需要捕获异常
4.2.6 独享锁 VS 共享锁
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。共享锁的特性是:读读共享,读写互斥,写写互斥。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
ReentrantLock重入锁是独享锁,ReentrantReadWriteLock重入读写锁是共享锁
(我们项目的LRU本地缓存就使用了重入读写锁+LRUMap来实现)
4.2.7 粗化锁 VS 分段锁
虚拟机会将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁,即锁的粗化。 stringBuffer的每次append操作都是一次加锁和解锁,多次连续的append操作会被解读成一个大范围的锁。
分段锁并不是一种具体的锁,而是一种概念。为了降低锁的竞争程度,我们可以将锁的粒度细化,缩小锁保护的范围,如ConcurrentHashMap就用到了分段锁的概念,由原来的一个锁拆分成细粒度的多个锁。
降低锁竞争的方法:
- 降低锁的粒度
- 减少锁持有的时间
- 避免使用独占锁,而是使用共享锁
4.2.8 死锁
死锁不是一种锁,而是是指多个进程在运行过程中因争夺资源而造成的一种僵局,如果没有外部因素影响他们将无法再往前推进。如图,线程A持有锁A去获取锁B,线程B持有锁B去获取锁,结果陷入僵持。
要想死锁存在,必须满足4个条件:
- 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
- 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
- 环路等待条件:在发生死锁时,必然存在一个进程请求资源的资源的环形链,例如A请求B的资源,B请求C的资源,C请求A的资源。
避免死锁只要破坏一上4个条件的任意一个即可:
- 以确定的顺序获得锁(破坏环路等待)
- 超时放弃(破坏互斥、请求和保存、不剥夺条件)
检测死锁:
首先为每个进程和每个资源指定一个唯一的号码,然后建立资源分配表和进程等待表,通常通过工具来进行(Jstack,JConsole)。
解决死锁:
- 重新启动:昂贵的代价
- 撤消进程/剥夺资源:撤销所有死锁线程,剥夺所有资源。或撤销代价最小(优先级、运行代价、进程的重要性和价值)的线程
5. 缓存的维护策略
- LRU(Least Recently Used):最近最少使用
- LFU(Least Frequently Uesd):最不经常使用
- FIFO(First in First Out):先进先出 当然缓存的更新策略会更加复杂,这里不作更多讨论。
6. 什么是Full GC?有什么影响?
Full GC 会调用单线程的垃圾收集器,导致Stop The World,带来各种问题。
7. 常见的GC面试题
如何判断对象是否死亡
垃圾回收算法:引用计数,可达性分析,GC回收
简单的介绍一下强引用、软引用、弱引用、虚引用
强引用OOM,软引用加快GC,后面两者不常用,虚引用可以用来追钟GC
垃圾收集有哪些算法,各自的特点?
复制清除法,标记清除法,标记整理法,分代回收算法
常见的垃圾回收器有那些?
串行回收器,并行回收器,CMS回收器,G1回收器,ZGC回收器
8. 简述java类的加载过程和类加载器
加载->验证->准备->解析->初始化
其中验证,准备,解析合称链接
- 加载通过类的完全限定名,查找此类.class字节码文件,利用字节码文件创建类对象.
- 验证确保Class文件符合当前虚拟机的要求,不会危害到虚拟机自身安全.
- 准备进行内存分配,为static修饰的类变量分配内存,并设置初始值(0或null).
不包含final修饰的静态变量,因为final变量在编译时分配. - 解析将常量池中的符号引用替换为直接引用的过程.直接引用为直接指向目标的指针或者相对偏移量等.
- 初始化主要完成静态块执行以及静态变量的赋值.先初始化父类,再初始化当前类.只有对类主动使用时才会初始化.触发条件包括,创建类的实例时,访问类的静态方法或静态变量的时候,使用Class.forName反射类的时候,或者某个子类初始化的时候.
java的类采用双亲委派方式进行加载:
- 向上委派,即加载器加载类时先把请求委托给自己的父类加载器执行,直到顶层的启动类加载器。
- 向下返回,父类加载器能够完成加载则成功返回,不能则子类加载器才自己尝试加载.
优点:
- 可以避免类的重复加载
- 避免核心父类class被子类加载后修改
如果要实现自定义的类加载器,只需要继承并实现ClassLoader类即可
java运行流程各内存区域的分配时机:
9. JDK1.8为什么用元空间替换永生代,永生代?
永生代的它的大小是在启动时固定好的很难进行调优,增添了未来的改进可能。
10. Error错误和Eception异常的区别
- Error是虚拟机本身出现的错误,如OOM等
- Exception是程序设计上的错误,例如强制转换失败
- Java类加载器有几种,关系怎样的?
- 双请问欧派机制的加载流程是谮言的,有什么好处?
-
14. 加分项
编译器优化
例如:如何在编程时通过栈的分配降低GC压力,如何编写适合内联优化的代码- 问题排查经验与思路
例如:解决过线上经常fullGC问题,排查过内存泄漏的问题 - JVM调优经验和调优思路
例如:针对高并发低延迟的场景,如何调整GC参数,尽量降低停顿时间 - 了解最新的技术趋势(ZGC和Graalvm)