某司社招面试题目整理及答案
- Java基础部分
- HashMap的实现原理
- 如何解决Hash碰撞
- HashMap的存储(hash算法、hash冲突、初始化、扩容)
- HashMap和HashTable的区别是什么?
- HashMap为什么线程不安全,如果想要线程安全怎么做?
- HashMap是怎么解决Hash冲突的
- 锁
- Java中锁的分类
- Synchronized
- Synchronized的使用
- Java的对象头和Monitor
- Synchronized原理
- Synchronized的问题
- 改进后的各种锁
- 其他锁的分类
- Java里的Lock
- AbstractQueuedSynchronizer
- 重入锁ReentrantLock
- 读写锁ReentrantReadWriteLock
- 关于锁的总结
- Spring vs Spring Boot
- 什么是Spring?
- 什么是SpringBoot?
- AOP
- AOP中的术语
- AOP两种代理方式
- JDK动态接口代理
- CGlib动态代理
- 内存模型
- Java运行时数据区
- JMM Java内存模型
- 堆的内存划分
- 垃圾回收
- 判断对象是否要回收的方法:可达性分析法
- 发现虚拟机频繁full GC时应该怎么办:
- 常见的垃圾回收算法:
- HotSpot 虚拟机详解
- JVM的优化
- 类加载机制
- 概念
- 类加载整体流程
- 双亲委派模型
- 类加载器:
- 编写步骤:
- 多线程
- 线程和进程的区别
- 线程的几种状态
- 创建线程的几种方法
- sleep和wait方法的区别
- yield和join区别
- ThreadLocal
- ThreadLocal是什么?
- ThreadLocalMap的内存泄露问题
- 创建线程池
- 几种常用的线程池
- newFixedThreadPool
- newCachedThreadPool
- newSingleThreadExecutor
- newScheduledThreadPool
- 数据库
- MySQL索引、事务、存储引擎
- MVVC
- InnoDB 下的 MVCC 实现原理
- 一致性视图的生成 ReadView
- 一致性读和当前读
- 参考资料
Java基础部分
HashMap的实现原理
HashMap
根据键的hashCode
值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。
HashMap非线程安全
,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections
的synchronizedMap
方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap
。HashMap
中的静态内部类如下。HashMap 是 JAVA 集合框架的成员。基于 [ 数组 + 链表
] 的数据结构存储 key-value
形式的数据。key
是每条数据的唯一标识,HashMap 通过一个hash
算法(也称散列算法
)根据 key 值计算出这条数据在数组中的位置,即数组下标,然后把数据装载到一个链表元素Node<K, V>
中,最后根据数组下标进行落桶(bucket
)操作。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
可以用下面这张图来介绍 HashMap 的结构。
如何解决Hash碰撞
- 如果两个输入的 hash 结果相同,则称这两个输入是一个碰撞(
Collision
)。 - 在JAVA中,采用“
链地址法
”解决 hash碰撞。HashMap 在数组中存放第一个落桶的节点,这个节点也是链表的 head节点,拥有一个 next 属性指向 null,当下一个相同 hash 值的元素落桶,则使此 head节点的 next 指向新的元素,即后来的节点作为链表的 tail节点。 - 由上图可知,数组在 0 和 2 的位置存放了节点k1/k2,当节点 k3 与 k2 发生了 hash碰撞,则使节点 k2 的 next 指向节点 k3。注意的是,在
JAVA8
中,为了提高检索效率,当链表的节点数量超过8个,并且整个数组容量超过 64 个,则把这个链表重载成红黑树(树化),否则进行 2 倍扩容并且重新散列(rehash)所有节点。树化操作是因为链表的检索是线性时间O(n),而红黑树是对数时间O(lgn)。这么处理大概是为了尽可能避免过早的把数据存放到桶外(形成长链表),因为桶数组的容量是参与元素索引计算的。
HashMap的存储(hash算法、hash冲突、初始化、扩容)
// 计算hash值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// PUT方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
// 如果对象已经存在,则不改变存在的值
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 这里的table是HashMap的桶数组,这个数组是需要制定容量的,默认16,属性"DEFAULT_INITIAL_CAPACITY"
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; // HashMap在第一次put的时候进行初始化
if ((p = tab[i = (n - 1) & hash]) == null) //判断即将落桶的位置是否已经有Node存在,即是否存在hash冲突
tab[i] = newNode(hash, key, value, null); //不存在hash冲突则直接落桶
else {
Node<K,V> e; K k;
// 判断是否在数组中存放的对象(即链表头节点)与新的对象的key值相同,如果相同直接提取到新的拷贝e中供后续操作
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode) // 如果p处于红黑树中,则调用TreeNode.putTreeVal()方法提取旧节点到e中供后续操作
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else { // 如果p在链表中
for (int binCount = 0; ; ++binCount) { // 遍历链表
if ((e = p.next) == null) { // 当整个链表不存在与新节点相同的key,则直接把新节点加入到链表的尾部
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st // 当链表元素数量到达指定阈值,默认8个,进行“树化”
treeifyBin(tab, hash);
break;
}
// 当找到与新节点相同的key,提取到e中供后续操作
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null) // onlyIfAbsent参数的意思是“是否不覆盖旧值”
e.value = value;
afterNodeAccess(e); // 这个方法是为了继承HashMap的LinkedHashMap类服务的,暂时不看
return oldValue;
}
}
++modCount; // 一个记录操作次数的变量
// 如果不是值覆盖会执行到这步,如果本次元素插入导致了桶数量超过阈值,则进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict); // 和afterNodeAccess()方法一样
return null;
}
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) { // 扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; // 修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && // 普通扩容
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) { // 计算新的扩容阈值
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 把每个bucket都移动到新的buckets中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null; // 删除了旧节点的引用(很细节)
if (e.next == null) // 当桶里只有一个节点,重新计算索引位置落桶
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 创建两条链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 直接用容量与 hash 位与运算,相当于舍掉低位特征,大概是针对hash冲突进行一次随机散列。结果为0的保持原来的索引位置不变
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { // 否则向右偏移旧数组容量
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; // 向右偏移旧数组容量
}
}
}
}
}
return newTab;
}
HashMap和HashTable的区别是什么?
- HashTable
- 底层数组+链表实现,无论key还是value都不能为null,线程安全,实现线程安全的方式是在修改数据时锁住整个HashTable,效率低,ConcurrentHashMap做了相关优化
- 初始size为11,扩容:newsize = oldsize*2+1
- 计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length
- HashMap
- 底层数组+链表实现,可以存储null键和null值,线程不安全
- 初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂
- 扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入
- 插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)
- 当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀
- 计算index方法:index = hash & (tab.length – 1)
- ConcurrentHashMap
- 底层采用分段的数组+链表实现,线程安全
- 通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)
- Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术
- 有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁
- 扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容
Hashtable和HashMap都实现了Map接口,但是Hashtable的实现是基于Dictionary抽象类的。Java5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。
HashMap为什么线程不安全,如果想要线程安全怎么做?
若想要线程安全
1、使用ConcurrentHashMap。(线程安全的hashMap)
2、使用Collections.synchronizedMap(Mao<K,V> m)方法把HashMap变成一个线程安全的Map。
HashMap是怎么解决Hash冲突的
在实际应用中,无论怎么构造哈希函数,冲突也难以完全避免。
HashMap根据链地址法(拉链法)来解决冲突,jdk8中如果链表长度大于8且节点数组长度大于64的时候,就把链表下所有节点转为红黑树,位于数组上的节点为根节点,来维护hash冲突的元素,链表中冲突的元素可以通过key的equals()方法来确定。
锁
Java中锁的分类
Java的锁分为两类:
- 第一类是
synchronized
同步关键字,这个关键字属于隐式的锁,是jvm
层面实现,使用的时候看不见; - 第二类是在 jdk5 后增加的
Lock
接口以及对应的各种实现类,这属于显式的锁,就是我们能在代码层面看到锁这个对象,而这些个对象的方法实现,大都是直接依赖CPU
指令的,无关 jvm 的实现。
Synchronized
Synchronized的使用
- 如果修饰的是具体对象:锁的是对象;
- 如果修饰的是成员方法:锁的就是this;
- 如果修饰的是静态方法:锁的就是对象.class;
Java的对象头和Monitor
对象在内存中的布局分为三块区域:对象头
、实例数据
和对齐填充
。
- 对象头。Hot Spot 虚拟机对象的对象头部分包括两类信息。第一类是用于存储对象自身的运行时数据,如
哈希码( Hash Code)
、GC分代年龄
、锁状态标志
、线程持有的锁
、偏向线程ID
、偏向时间戳
等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word
”。 - 实例数据。实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
这部分的存储顺序会受到虚拟机分配策略参数 (-XX: Fields Allocation Style参数
) 和字段在Java源码中定义顺序的影响。Hot Spot
虚拟机默认的分配顺序为 longs/doubles、ints、shorts/chars、bytes/booleans、oops( Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 Hotspot 虚拟机的 XX: Compact Fields 参数值为 true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。 - 对齐填充。并不是必然存在的,由于 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
显然,和锁相关的就是对象头里存储的那几个内容:
- 其中的重量级锁也就是通常说
synchronized
的对象锁,其中指针指向的是monitor
对象(也称为管程
或监视器锁
)的起始地址。每个对象都存在着一个 monitor 与之关联,monitor 是由ObjectMonitor 实现的,C++实现。 - 注意到还有轻量级锁,这是在 jdk6 之后对 synchronized 关键字底层实现的改进。
Synchronized原理
Synchronized 和对象头里的指令有关,Java虚拟机可以支持方法级的同步
和方法内部一段指令序列(代码块)的同步
,这两种同步结构都是使用管程( Monitor,更常见的是直接将它称为“锁”) 来实现的。
对于 synchronized 修饰方法(包括普通和静态方法)、修饰代码块,这两种用法的实现略有不同:
- Synchronized修饰方法
public class LockDemo {
private static int i;
public synchronized static void syncTask() {
i++;
}
}
然后反编译 class文件,可以看到:
其中的方法表示:
- ACC_PUBLIC: 代表public修饰符
- ACC_STATIC:代表是静态方法
- ACC_SYNCHRONIZED: 指明该方法为同步方法
方法级的同步是隐式的。无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否被声明为同步方法。(静态方法也是如此)
- 当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程(Monitor),然后才能执行方法,最后当方法完成 (无论是正常完成还是非正常完成)时释放管程。
- 在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。
- 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。
- Synchronized修饰代码块
public class LockDemo {
private static int i;
public void syncTask() {
synchronized (this) {
i++;
}
}
}
然后反编译class文件:
可以看到,在指令方面多了关于 Monitor 操作的指令,或者和上一种修饰方法的区别来看,是显式的用指令去操作管程(Monitor)了。
同步一段指令集序列的情况。Java虚拟机的指令集中有 monitorenter
和 monitorexit
两条指令来支持 synchronized 关键字的语义。(monitorenter 和 monitorexit 两条指令是 C 语言的实现)正确实现 synchronized 关键字需要 Javac 编译器
与 Java 虚拟机
两者共同协作支持。Monitor的实现基本都是 C++ 代码,通过JNI
(java native interface
)的操作,直接和cpu的交互编程。
Synchronized的问题
早期的 synchronized 的实现就是基于上面所讲的原理,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。
从加锁到最后变成以前的那种重量级锁的过程里,新实现出状态不同的锁作为过渡。
改进后的各种锁
偏向锁->自旋锁->轻量级锁->重量级锁。按照这个顺序,锁的重量依次增加。
- 偏向锁。意思是这个锁会偏向于第一个获得它的线程,当这个线程再次请求锁的时候不需要进行任何同步操作,从而提高性能。那么处于偏向锁模式的时候,对象头的Mark Word 的结构会变为偏向锁结构。
- 轻量级锁。当偏向锁的条件不满足,亦即的确有多线程并发争抢同一锁对象时,但并发数不大时,优先使用轻量级锁。一般只有两个线程争抢锁标记时,优先使用轻量级锁。此时,对象头的Mark Word 的结构会变为轻量级锁结构。
轻量级锁是和传统的重量级锁相比较的,传统的锁使用的是操作系统的互斥量,而轻量级锁是虚拟机基于 CAS 操作进行更新,尝试比较并交换,根据情况决定要不要改为重量级锁。(这个动态过程也就是自旋锁的过程了) - 重量级锁。重量级锁即为具有完整Monitor功能的锁。
- 自旋锁。自旋锁是一个过渡锁,是从轻量级锁到重量级锁的过渡。也就是CAS。
CAS
,全称为Compare-And-Swap
,是一条CPU的原子指令
,其作用是让CPU比较后原子地更新某个位置的值,实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的,JVM 只是封装了汇编调用,那些AtomicInteger类便是使用了这些封装后的接口。
注意:Java中的各种锁对程序员来说是透明的: 在创建锁时,JVM 先创建最轻的锁,若不满足条件则将锁逐次升级.。这四种锁之间只能升级,不能降级。
其他锁的分类
上面说的锁都是基于 synchronized 关键字,以及底层的实现涉及到的锁的概念,还有一些别的角度的锁分类:
按照锁的特性分类:
- 悲观锁:独占锁,会导致其他所有需要所的线程都挂起,等待持有所的线程释放锁,就是说它的看法比较悲观,认为悲观锁认为对于同一个数据的并发操作,一定是会发生修改的。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。比如前面讲过的,最传统的 synchronized 修饰的底层实现,或者重量级锁。(但是现在synchronized升级之后,已经不是单纯的悲观锁了)
- 乐观锁:每次不是加锁,而是假设没有冲突而去试探性的完成操作,如果因为冲突失败了就重试,直到成功。比如 CAS 自旋锁的操作,实际上并没有加锁。
按照锁的顺序分类:
- 公平锁。公平锁是指多个线程按照申请锁的顺序来获取锁。java 里面可以通过 ReentrantLock 这个锁对象,然后指定是否公平
- 非公平锁。非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。使用 synchronized 是无法指定公平与否的,它是不公平的。
独占锁(也叫排他锁)/共享锁:
- 独占锁,也叫排他锁,是指该锁一次只能被一个线程所持有。对 ReentrantLock 和 Sychronized 而言都是独占锁。
- 共享锁:是指该锁可被多个线程所持有。对 ReentrantReadWriteLock 而言,其读锁是共享锁,其写锁是独占锁。读锁的共享性可保证并发读是非常高效的,读写、写读、写写的过程都是互斥的。
独占锁/共享锁是一种广义的说法,互斥锁/读写锁是java里具体的实现。
Java里的Lock
synchronized 关键字下层的锁,是在 jvm 层面实现的,而后来在 jdk 5 之后,在 juc 包里有了显式的锁,Lock 完全用 Java 写成,在java这个层面是无关JVM实现的。虽然 Lock 缺少了 (通过 synchronized 块或者方法所提供的) 隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种 synchronized 关键字所不具备的同步特性。
Lock是一个接口,实现类常见的有:
- 重入锁(
ReentrantLock
) - 读锁(
ReadLock
) - 写锁(
WriteLock
)
实现基本都是通过聚合了一个同步器(AbstractQueuedSynchronizer 缩写为 AQS)的子类来完成线程访问控制的。
在jdk的源码中可以看到:
这里面的各个锁实现了 Lock 接口,然后任意打开一个类,可以发现里面的实现,Lock 的操作借助于内部类 Sync,而 Sync 是继承了 AbstractQueuedSynchronizer类的,这个类就是很重要的一个 AQS 类。
整体来看,这些类的关系还是挺复杂:
不过一般的直接使用还是很简单,比如 new 一个锁,然后在需要的操作之前之后分别加锁和释放锁。
在Lock接口里定义的方法有6个,如下所示:
其含义如下:
AbstractQueuedSynchronizer
队列同步器 AbstractQueuedSynchronizer
(以下简称同步器或者 AQS),是用来构建锁或者其他同步组件的基础框架,它使用了一个 int 成员变量表示同步状态,通过内置的 FIFO 队列来完成资源获取线程的排队工作。
同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的 3 个方法来进行操作,因为它们能够保证状态的改变是安全的。
这三个方法分别是:
- protected final int getState() // 获取当前同步状态
- protected final void setState(int newState) //设置当前同步状态
- protected final boolean compareAndSetState(int expect, int update) // 使用CAS设置当前状态,还方法能够保证状态设置的原子性
子类推荐被定义为自定义同步组件的静态内部类,同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件 (ReentrantLock
、 ReentrantReadWriteLock
和 CountDownLatch
等)。
AQS 定义的三类模板方法;
- 独占式同步状态获取与释放
- 共享式同步状态获取与释放
- 同步状态和查询同步队列中的等待线程情况
同步器的内置 FIFO 队列,从源码里可以看到,Node 就是保存着线程引用和线程状态的容器。
- 每个线程对同步器的访问,都可以看做是队列中的一个节点(Node)。
- 节点是构成同步队列的基础,同步器拥有首节点 (head) 和尾节点 (tail);
- 没有成功获取同步状态的线程将会成为节点加入该队列的尾部。
- 首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。
重入锁ReentrantLock
- 重入锁 ReentrantLock,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。
- 除此之外,该锁的还支持获取锁时的公平和非公平性选择。
ReentrantLock 支持公平与非公平选择,内部实现机制为:
- 内部基于
AQS
实现一个公平与非公平公共的父类Sync
,(在代码里,Sync 是一个内部类,继承 AQS)用于管理同步状态;
-FairSync
继承 Sync 用于处理公平问题; NonfairSync
继承 Sync 用于处理非公平问题。
读写锁ReentrantReadWriteLock
独占锁(排他锁)/共享锁,具体实现层面就对应 java 里的互斥锁/读写锁。
- ReentrantLock、synchronized 都是排他锁;
- ReentrantReadWriteLock 里面维护了一个读锁、一个写锁,其中读锁是共享锁,写锁是排他锁。
源码如下:
因为分了读写锁,ReentrantReadWriteLock 锁没有直接实现 Lock 接口,它的内部是这样的: - 基于 AQS 实现一个公平与非公平公共的父类 Sync ,用于管理同步状态;
- FairSync 继承 Sync 用于处理公平问题;
- NonfairSync 继承 Sync 用于处理非公平问题;
- ReadLock 实现 Lock 接口,内部聚合 Sync;
- WriteLock 实现 Lock 接口,内部聚合 Sync。
关于锁的总结
java 的对象都有与之关联的一个锁,这个锁称为监视器锁或者内部锁,通过关键字 synchronized 声明来使用,实际是 jvm 层面实现的,向下则用到了 Monitor 类,再向下虚拟机的指令则是和 CPU 打交道,插入内存屏障等等操作。
而 jdk 5 之后引入了显式的锁,以 Lock 接口为核心的各种实现类,他们完全由 java 实现逻辑,那么实现类还要基于 AQS 这个队列同步器,AQS 屏蔽了同步状态管理、线程排队与唤醒等底层操作,提供模板方法,聚合到 Lock 的实现类里去实现。
对比一下隐式和显式锁:
- 隐式锁基本没有灵活性可言,因为 synchronized 控制的代码块无法跨方法,修饰的范围很窄;而显示锁则本身就是一个对象,可以充分发挥面向对象的灵活性,完全可以在一个方法里获得锁,另一个方法里释放。
- 隐式锁简单易用且不会导致内存泄漏;而显式锁的过程完全要程序员控制,容易导致锁泄露;
- 隐式锁只是非公平锁;显示锁支持公平/非公平锁;
- 隐式锁无法限制等待时间、无法对锁的信息进行监控;显示锁提供了足够多的方法来完成灵活的功能;
- 一般来说,我们默认情况下使用隐式锁,只在需要显示锁的特性的时候才选用显式锁。
对比完了 synchronized 和 Lock 两个锁。对于 java 的线程同步机制,往往还会提到的另外两个内容就是volatile
关键字和 CAS
操作以及对应的原子类。因此这里再提一下:
- volatile 关键字常被称为轻量级的 synchronized,实际上这两个完全不是一个东西。synchronized 通过的是 jvm 层面的管程隐式的加了锁。而 volatile 关键字则是另一个角度,jvm 也采用相应的手段,保证:
- 被它修饰的变量的可见性:线程对变量进行修改后,要立刻写回主内存;
- 线程对变量读取的时候,要从主内存读,而不是缓存;
- 在它修饰变量上的操作禁止指令重排序。 - CAS 是一种 CPU 的指令,也不属于加锁,它通过假设没有冲突而去试探性的完成操作,如果因为冲突失败了就重试,直到成功。那么实际上我们很少直接使用 CAS ,但是 java 里提供了一些原子变量类,就是 juc 包里面的各种Atomicxxx类,这些类的底层实现直接使用了 CAS 操作来保证使用这些类型的变量的时候,操作都是原子操作,当使用他们作为共享变量的时候,也就不存在线程安全问题了。
Spring vs Spring Boot
什么是Spring?
Spring框架为开发 Java应用程序提供了全面的基础架构支持。它包含一些很好的功能,如依赖注入和开箱即用的模块,如:SpringJDBC、SpringMVC、SpringSecurity、SpringAOP、SpringORM、SpringTest,这些模块缩短应用程序的开发时间,提高了应用开发的效率例如,在 JavaWeb开发的早期阶段,我们需要编写大量的代码来将记录插入到数据库中。但是通过使用 SpringJDBC模块的 JDBCTemplate,我们可以将操作简化为几行代码。
什么是SpringBoot?
SpringBoot基本上是 Spring框架的扩展,它消除了设置 Spring应用程序所需的 XML配置,为更快,更高效的开发生态系统铺平了道路。
SpringBoot中的一些特征:
1、创建独立的 Spring应用。
2、嵌入式 Tomcat、 Jetty、 Undertow容器(无需部署war文件)。
3、提供的 starters 简化构建配置
4、尽可能自动配置 spring应用。
5、提供生产指标,例如指标、健壮检查和外部化配置
6、完全没有代码生成和 XML配置要求
简而言之,可以说 SpringBoot只是 Spring本身的扩展,使开发,测试和部署更加方便。
AOP
Spring框架中的两大核心思想:IOC
和AOP
。其中AOP,即面向切面编程。AOP关注的是切面,并且切面在AOP中是模块化的,如事务管理、日志管理等都可以作为切面来在需要的地方提供相应的服务。同时,AOP也是对IOC的一种增强,但IOC和AOP之间是独立的。其中,AOP最重要的两个思想点是:
- 提供声明式企业服务。此类服务中最重要的是声明式事务管理。AOP出现之前,对于事务管理我们都是通过编程式的事务管理,比如手动提交、回滚等。而AOP的事务管理是声明式的,可以简单的通过注解来实现,大大简化了对于事务的操作。
- 用户可以自定义切面。这意味着你可以在程序运行期间织入自己想要实现的一些功能。
AOP中的术语
-
Aspect
:切面。对关注点的模块化,所谓关注点可以理解为应用程序中公共部分代码逻辑,比如对事物的管理、日志的管理等代码逻辑可以作为一个模块来供其他代码中使用,该模块在AOP中通常称为切面。具体实现方式:xml配置或者注解。注解的方式通俗的来说就是在普通类上面加一个@Aspect注解。 -
Join point
:连接点。程序执行期间的一个点,如方法执行或异常处理期间的一个点。在Spring AOP中,连接点总是表示方法执行。连接点正是通知方法执行的时机。 -
advice
:通知。用来定义在切面的连接点上采取的操作(某一个方法)。通知类型有五种,以注解的方式来看包括:@Before、@After、@AfterReturning、@AfterThrowing、@Around -
Pointcut
:切入点。从注解的方式来看,切入点是用来定义连接点的一种表达式,通知就是执行在切入点定义好的连接点之上的。用切入点来定义连接点可以在通知中更简洁的引入,当然也可以不用切点来定义连接点,连接点可以直接在通知中进行定义。 -
Introduction
: 引入。引入其他方法或字段。Spring AOP允许向任何被建议的对象引入新的接口(以及相应的实现)。例如,可以让bean实现一个IsModified接口,以简化缓存。(在AspectJ社区中,引入称为类型间声明。) -
Target object
:目标对象。被一个或多个切面通知的对象。也称为被通知的对象。因为Spring AOP是通过使用运行时代理实现的,所以这个对象始终是一个代理对象。 -
AOP proxy
:AOP框架为了实现切面契约(通知方法的执行等等)而创建的对象。在Spring框架中,AOP代理是JDK动态代理或CGLIB代理。 -
Weaving
:织入。将切面与其他应用程序类型或对象链接以此来创建一个通知对象。这可以在编译时(例如使用AspectJ编译器)、加载时或运行时完成。与其他纯Java AOP框架一样,Spring AOP在运行时完成织入。
AOP两种代理方式
Spring提供了两种方式来生成代理对象: JDKProxy
和Cglib
,具体使用哪种方式生成由AopProxyFactory
根据AdvisedSupport
对象的配置来决定。默认的策略是如果目标类是接口,则使用JDK动态代理技术,否则使用Cglib来生成代理。
JDK动态接口代理
JDK动态代理主要涉及到java.lang.reflect
包中的两个类:Proxy
和InvocationHandler
。InvocationHandler是一个接口,通过实现该接口定义横切逻辑,并通过反射机制调用目标类的代码,动态将横切逻辑和业务逻辑编制在一起。Proxy利用InvocationHandler动态创建一个符合某一接口的实例,生成目标类的代理对象。
CGlib动态代理
CGLib
全称为Code Generation Library
,是一个强大的高性能,高质量的代码生成类库,可以在运行期扩展Java类与实现Java接口,CGLib封装了asm,可以再运行期动态生成新的class。
和JDK动态代理相比较:JDK创建代理有一个限制,就是只能为接口创建代理实例,而对于没有通过接口定义业务方法的类,则可以通过CGLib创建动态代理。
内存模型
Java运行时数据区
Java虚拟机所管理的内存包括以下几个运行时数据区域,如下图:
各个部分的含义如下:
-
程序计数器
:指向当前线程正在执行的字节码指令。线程私有的。 虚拟机栈
:虚拟机栈是Java执行方法的内存模型。每个方法被执行的时候,都会创建一个栈帧,把栈帧压入栈,当方法正常返回或者抛出未捕获的异常时,栈帧就会出栈。
- 栈帧:栈帧存储方法的相关信息,包含局部变量数表、返回值、操作数栈、动态链接
局部变量表:包含了方法执行过程中的所有变量。局部变量数组所需要的空间在编译期间完成分配,在方法运行期间不会改变局部变量数组的大小。
返回值:如果有返回值的话,压入调用者栈帧中的操作数栈中,并且把PC的值指向方法调用指令后面的一条指令地址。
操作数栈:操作变量的内存模型。操作数栈的最大深度在编译的时候已经确定(写入方法区code属性的max_stacks项中)。操作数栈的的元素可以是任意Java类型,包括long和double,32位数据占用栈空间为1,64位数据占用2。方法刚开始执行的时候,栈是空的,当方法执行过程中,各种字节码指令往栈中存取数据。
动态链接:每个栈帧都持有在运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。 - 线程私有
-
本地方法栈
:调用本地native的内存模型,且线程独享。 方法区
:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据
- 线程共享的
- 运行时常量池:是方法区的一部分,其中存放编译期生成的各种字面量和符号引用。
Class文件中除了存有类的版本、字段、方法、接口等描述信息,还有一项是常量池,存有这个类在编译期生成的各种字面量和符号引用,这部分内容将在类加载后,存放到方法区的运行时常量池中。
-
堆(Heap)
:Java对象存储的地方,是虚拟机管理的内存中最大的一块,是所有线程共享的区域,在虚拟机启动时创建,唯一目的就是存放对象实例,几乎所有对象实例都在这里分配内存。存放new生成的对象和数组;是垃圾收集器管理的内存区域,因此很多时候称为“GC堆”
JMM Java内存模型
- Java的并发采用“共享内存”模型,线程之间通过读写内存的公共状态进行通讯。多个线程之间是不能通过直接传递数据交互的,它们之间交互只能通过共享变量实现。
- 主要目的是定义程序中各个变量的访问规则。
- Java内存模型规定所有变量都存储在主内存中,每个线程还有自己的工作内存。
- 线程的工作内存中保存了被该线程使用到的变量的拷贝(从主内存中拷贝过来),线程对变量的所有操作都必须在工作内存中执行,而不能直接访问主内存中的变量。
- 不同线程之间无法直接访问对方工作内存的变量,线程间变量值的传递都要通过主内存来完成。
- 主内存主要对应Java堆中实例数据部分。工作内存对应于虚拟机栈中部分区域。
- Java线程之间的通信由内存模型JMM(Java Memory Model)控制。
- JMM决定一个线程对变量的写入何时对另一个线程可见。
- 线程之间共享变量存储在主内存中
- 每个线程有一个私有的本地内存,里面存储了读/写共享变量的副本。
- JMM通过控制每个线程的本地内存之间的交互,来为程序员提供内存可见性保证。
- 可见性、有序性:
- 当一个共享变量在多个本地内存中有副本时,如果一个本地内存修改了该变量的副本,其他变量应该能够看到修改后的值,此为可见性。
- 保证线程的有序执行,这个为有序性。(保证线程安全)
- 内存间交互操作:
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把主内存的一个变量读取到工作内存中。
- load(载入):作用于工作内存,把read操作读取到工作内存的变量载入到工作内存的变量副本中
- use(使用):作用于工作内存的变量,把工作内存中的变量值传递给一个执行引擎。
- assign(赋值):作用于工作内存的变量。把执行引擎接收到的值赋值给工作内存的变量。
- store(存储):把工作内存的变量的值传递给主内存
- write(写入):把store操作的值入到主内存的变量中
- 注意:
- 不允许read、load、store、write操作之一单独出现
- 不允许一个线程丢弃assgin操作
- 不允许一个线程不经过assgin操作,就把工作内存中的值同步到主内存中
- 一个新的变量只能在主内存中生成
- 一个变量同一时刻只允许一条线程对其进行lock操作。但lock操作可以被同一条线程执行多次,只有执行相同次数的unlock操作,变量才会解锁
- 如果对一个变量进行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或者assgin操作初始化变量的值。
- 如果一个变量没有被锁定,不允许对其执行unlock操作,也不允许unlock一个被其他线程锁定的变量
- 对一个变量执行unlock操作之前,需要将该变量同步回主内存中
堆的内存划分
Java堆的内存划分如图所示,分别为年轻代、Old Memory(老年代)、Perm(永久代)。其中在Jdk1.8中,永久代被移除,使用MetaSpace代替。
- 新生代:
- 使用复制清除算法(Copinng算法),原因是年轻代每次GC都要回收大部分对象。新生代里面分成一份较大的
Eden
空间和两份较小的Survivor
空间。每次只使用Eden和其中一块Survivor空间,然后垃圾回收的时候,把存活对象放到未使用的Survivor(划分出from、to)空间中,清空Eden和刚才使用过的Survivor空间。 - 分为Eden、Survivor From、Survivor To,比例默认为8:1:1
- 内存不足时发生Minor GC
- 老年代:
- 采用标记-整理算法(mark-compact),原因是老年代每次GC只会回收少部分对象。
- Perm:用来存储类的元数据,也就是方法区。
- Perm的废除:在jdk1.8中,Perm被替换成MetaSpace,MetaSpace存放在本地内存中。原因是永久代进场内存不够用,或者发生内存泄漏。
- MetaSpace(元空间):元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
- 堆内存的划分在JVM里面的示意图:
垃圾回收
判断对象是否要回收的方法:可达性分析法
- 可达性分析法:
通过一系列“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的。不可达对象不一定会成为可回收对象。进入DEAD状态的线程还可以恢复,GC不会回收它的内存。(把一些对象当做root对象,JVM认为root对象是不可回收的,并且root对象引用的对象也是不可回收的) - 以下对象会被认为是root对象:
- 虚拟机栈(栈帧中本地变量表)中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中Native方法引用的对象
- 对象被判定可被回收,需要经历两个阶段:
- 第一个阶段是可达性分析,分析该对象是否可达
- 第二个阶段是当对象没有重写
finalize()
方法或者finalize()方法已经被调用过,虚拟机认为该对象不可以被救活,因此回收该对象。(finalize()方法在垃圾回收中的作用是,给该对象一次救活的机会)
- 方法区中的垃圾回收:
- 常量池中一些常量、符号引用没有被引用,则会被清理出常量池
- 无用的类:**被判定为无用的类,会被清理出方法区。**判定方法如下:
- 该类的所有实例被回收
- 加载该类的
ClassLoader
被回收 - 该类的
Class对象
没有被引用
- finalize():
- GC垃圾回收要回收一个对象的时候,调用该对象的finalize()方法。然后在下一次垃圾回收的时候,才去回收这个对象的内存。
- 可以在该方法里面,指定一些对象在释放前必须执行的操作。
发现虚拟机频繁full GC时应该怎么办:
full GC
指的是清理整个堆空间,包括年轻代和永久代
- 首先用命令查看触发GC的原因是什么
jstat –gccause 进程id
- 如果是
System.gc()
,则看下代码哪里调用了这个方法 - 如果是
heap inspection
(内存检查),可能是哪里执行jmap –histo[:live]命令
- 如果是
GC locker
,可能是程序依赖的JNI库的原因
常见的垃圾回收算法:
-
Mark-Sweep
(标记-清除算法):
(1)思想:标记清除算法分为两个阶段,标记阶段和清除阶段。标记阶段任务是标记出所有需要回收的对象,清除阶段就是清除被标记对象的空间。
(2)优缺点:实现简单,容易产生内存碎片 -
Copying
(复制清除算法):
(1)思想:将可用内存划分为大小相等的两块,每次只使用其中的一块。当进行垃圾回收的时候了,把其中存活对象全部复制到另外一块中,然后把已使用的内存空间一次清空掉。
(2)优缺点:不容易产生内存碎片;可用内存空间少;存活对象多的话,效率低下。 -
Mark-Compact
(标记-整理算法):
(1)思想:先标记存活对象,然后把存活对象向一边移动,然后清理掉端边界以外的内存。
(2)优缺点:不容易产生内存碎片;内存利用率高;存活对象多并且分散的时候,**移动次数多,**效率低下 -
分代收集算法
:(目前大部分JVM的垃圾收集器所采用的算法)
思想:把堆分成新生代和老年代。(永久代指的是方法区)
- 因为新生代每次垃圾回收都要回收大部分对象,所以
新生代采用Copying算法
。新生代里面分成一份较大的Eden
空间和两份较小的Survivor
空间。每次只使用Eden和其中一块Survivor空间,然后垃圾回收的时候,把存活对象放到未使用的Survivor(划分出from、to)空间中,清空Eden和刚才使用过的Survivor空间。 - 由于老年代每次只回收少量的对象,因此采用
mark-compact
算法。 - 在堆区外有一个永久代。对永久代的回收主要是无效的类和常量
- GC使用时对程序的影响?
垃圾回收会影响程序的性能,Java虚拟机必须要追踪运行程序中的有用对象,然后释放没用对象,这个过程消耗处理器时间 - 几种不同的垃圾回收类型:
(1)Minor GC
:从年轻代(包括Eden、Survivor区)回收内存。
* 当JVM无法为一个新的对象分配内存的时候,越容易触发Minor GC。所以分配率越高,内存越来越少,越频繁执行Minor GC
* 执行Minor GC操作的时候,不会影响到永久代(Tenured)。从永久代到年轻代的引用,被当成GC Roots,从年轻代到老年代的引用在标记阶段直接被忽略掉。
(2)Major GC
:清理整个老年代,当eden区内存不足时触发。
(3)Full GC
:清理整个堆空间,包括年轻代和老年代。当老年代内存不足时触发
HotSpot 虚拟机详解
- Java对象创建过程:
(1)虚拟机遇到一条new
指令时,首先检查这个指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经加载、连接和初始化。如果没有,就执行该类的加载过程。
(2)为该对象分配内存。
- 假设Java堆是规整的,所有用过的内存放在一边,空闲的内存放在另外一边,中间放着一个指针作为分界点的指示器。那分配内存只是把指针向空闲空间那边挪动与对象大小相等的距离,这种分配称为“
指针碰撞
” - 假设Java堆不是规整的,用过的内存和空闲的内存相互交错,那就没办法进行“指针碰撞”。虚拟机通过维护一个列表,记录哪些内存块是可用的,在分配的时候找出一块足够大的空间分配给对象实例,并更新表上的记录。这种分配方式称为“
空闲列表
“。 - 使用哪种分配方式由Java堆是否规整决定。
Java堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定
。 - 分配对象保证线程安全的做法:虚拟机使用CAS失败重试的方式保证更新操作的原子性。(还有另外一种方案:每个线程在Java堆中预先分配一小块内存,称为
本地线程分配缓冲
,TLAB
。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才进行同步锁定。虚拟机是否使用TLAB,由-XX:+/-UseTLAB
参数决定)
(3)虚拟机为分配的内存空间初始化为零值(默认值)
(4)虚拟机对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到对象的元数据信息、对象的Hash码、对象的GC分代年龄等信息。这些信息存放在对象的对象头中。
(5) 执行方法,把对象按照程序员的意愿进行初始化。
- 对象的定位访问的方式(通过引用如何去定位到堆上的具体对象的位置):
(1)句柄:使用句柄
的方式,Java堆中将会划分出一块内存作为作为句柄池,引用中存储的就是对象的句柄的地址。而句柄中包含了对象实例数据和对象类型数据的地址。
(2)直接指针:使用直接指针
的方式,引用中存储的就是对象的地址。Java堆对象的布局必须必须考虑如何去访问对象类型数据。
(3)两种方式各有优点:
- 使用句柄访问的好处是引用中存放的是稳定的句柄地址,当对象被移动(比如说垃圾回收时移动对象),只会改变句柄中实例数据指针,而引用本身不会被修改。
- 使用直接指针,节省了一次指针定位的时间开销。
HotSpot
的GC算法实现:
(1)HotSpot怎么快速找到GC Root?
HotSpot使用一组称为OopMap
的数据结构。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在栈和寄存器中哪些位置是引用。这样子,在GC扫描的时候,就可以直接知道哪些是可达对象了。
(2)安全点:
- HotSpot只在特定的位置生成OopMap,这些位置称为安全点
。
- 程序执行过程中并非所有地方都可以停下来开始GC,只有在到达安全点是才可以暂停。
- 安全点的选定基本上以“是否具有让程序长时间执行“的特征选定的。比如说方法调用、循环跳转、异常跳转等。具有这些功能的指令才会产生Safepoint。
(3)中断方式:
-抢占式中断
:在GC发生时,首先把所有线程中断,如果发现有线程不在安全点上,就恢复线程,让它跑到安全点上。
-主动式中断
:GC需要中断线程时,不直接对线程操作,仅仅设置一个标志,各个线程执行时主动去轮询
这个标志,当发现中断标记为真就自己中断挂起。轮询标记的地方和安全点是重合的。
(4)安全区域:
- 一段代码片段中,对象的引用关系不会发生变化,在这个区域中任何地方开始GC都是安全的。在线程进入安全区域时,它首先标志自己已经进入安全区域,在这段时间里,当JVM发起GC时,就不用管进入安全区域的线程了。
- 在线程将要离开安全区域时,它检查系统是否完成了GC过程,如果完成了,它就继续前行。否则,它就必须等待直到收到可以离开安全区域的信号。- GC时为什么要停顿所有Java线程?
因为GC先进行可达性分析。可达性分析是判断GC Root对象到其他对象是否可达,假如分析过程中对象的引用关系在不断变化,分析结果的准确性就无法得到保证。 - CMS收集器:
(1)一种以获取最短回收停顿时间为目标的收集器。
(2)一般用于互联网站或者B/S系统的服务端
(3)基于标记-清除算法的实现,不过更为复杂,整个过程为4个步骤:初始标记
:标记GC Root能直接引用的对象并发标记
:利用多线程对每个GC Root对象进行tracing搜索,在堆中查找其下所有能关联到的对象。重新标记
:为了修正并发标记期间,用户程序继续运作而导致标志产生变动的那一部分对象的标记记录。并发清除
:利用多个线程对标记的对象进行清除
(4)由于耗时最长的并发标记和并发清除操作都是用户线程一起工作,所以总体来说,CMS的内存回收工作是和用户线程一起并发执行的。
(5)缺点:
- 对CPU资源占用比较多。可能因为占用一部分CPU资源导致应用程序响应变慢。
- CMS无法处理浮动垃圾。在并发清除阶段,用户程序继续运行,可能产生新的内存垃圾,这一部分垃圾出现在标记过程之后,因此,CMS无法清除。这部分垃圾称为“
浮动垃圾
“ - 需要预留一部分内存,在垃圾回收时,给用户程序使用。
- 基于标记-清除算法,容易产生大量内存碎片,导致full GC(full GC进行内存碎片的整理)
- 对象头部分的内存布局:
HotSpot的对象头分为两部分,第一部分用于存储对象自身的运行时数据,比如哈希码、GC分代年龄等。另外一部分用于指向方法区对象类型数据的指针。 - 偏向锁:
偏向锁偏向于第一个获取它的线程,如果在接下来的执行过程,没有其他线程获取该锁,则持有偏向锁的线程永远不需要同步。(当一个线程获取偏向锁,它每次进入这个锁相关的同步块,虚拟机不在进行任何同步操作。当有另外一个线程尝试获取这个锁时,偏向模式宣告结束)
JVM的优化
- 一般来说,当survivor区不够大或者占用量达到50%,就会把一些对象放到老年区。通过设置合理的eden区,survivor区及使用率,可以将年轻对象保存在年轻代,从而避免full GC,使用-Xmn设置年轻代的大小
- 对于占用内存比较多的大对象,一般会选择在老年代分配内存。如果在年轻代给大对象分配内存,年轻代内存不够了,就要在eden区移动大量对象到老年代,然后这些移动的对象可能很快消亡,因此导致full GC。通过设置参数:-XX:PetenureSizeThreshold=1000000,单位为B,标明对象大小超过1M时,在老年代(tenured)分配内存空间。
- 一般情况下,年轻对象放在eden区,当第一次GC后,如果对象还存活,放到survivor区,此后,每GC一次,年龄增加1,当对象的年龄达到阈值,就被放到tenured老年区。这个阈值可以通过
-XX:MaxTenuringThreshold
设置。如果想让对象留在年轻代,可以设置比较大的阈值。 - 设置最小堆和最大堆:
-Xmx
和-Xms
稳定的堆大小堆垃圾回收是有利的,获得一个稳定的堆大小的方法是设置-Xms和-Xmx的值一样,即最大堆和最小堆一样,如果这样设置,系统在运行时堆大小理论上是恒定的,稳定的堆空间可以减少GC次数,因此,很多服务端都会将这两个参数设置为一样的数值。稳定的堆大小虽然减少GC次数,但是增加每次GC的时间,因为每次GC要把堆的大小维持在一个区间内。 - 一个不稳定的堆并非毫无用处。在系统不需要使用大内存的时候,压缩堆空间,使得GC每次应对一个较小的堆空间,加快单次GC次数。基于这种考虑,JVM提供两个参数,用于压缩和扩展堆空间。
-
-XX:MinHeapFreeRatio
参数用于设置堆空间的最小空闲比率。默认值是40,当堆空间的空闲内存比率小于40,JVM便会扩展堆空间 -
-XX:MaxHeapFreeRatio
参数用于设置堆空间的最大空闲比率。默认值是70, 当堆空间的空闲内存比率大于70,JVM便会压缩堆空间。
当-Xmx和-Xmx相等时,上面两个参数无效
- 通过增大吞吐量提高系统性能,可以通过设置并行垃圾回收收集器。
-
-XX:+UseParallelGC
:年轻代使用并行垃圾回收收集器。这是一个关注吞吐量的收集器,可以尽可能的减少垃圾回收时间。 -
-XX:+UseParallelOldGC
:设置老年代使用并行垃圾回收收集器。
- 尝试使用大的内存分页:使用大的内存分页增加CPU的内存寻址能力,从而系统的性能。
-
-XX:+LargePageSizeInBytes
设置内存页的大小
- 使用非占用的垃圾收集器。
-XX:+UseConcMarkSweepGC
老年代使用CMS收集器降低停顿。 -
-XXSurvivorRatio=3
,表示年轻代中的分配比率:survivor:eden = 2:3 - JVM性能调优的工具:
jps(Java Process Status)
:输出JVM中运行的进程状态信息(现在一般使用jconsole)jstack
:查看java进程内线程的堆栈信息。jmap
:用于生成堆转存快照jhat
:用于分析jmap生成的堆转存快照(一般不推荐使用,而是使用Ecplise Memory Analyzer)jstat
是JVM统计监测工具。可以用来显示垃圾回收信息、类加载信息、新生代统计信息等。VisualVM
:故障处理工具
类加载机制
概念
类加载器把class文件中的二进制数据读入到内存中,存放在方法区,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。
类加载整体流程
类加载的步骤如下:
- 加载:查找并加载类的二进制数据(把class文件里面的信息加载到内存里面)
- 连接:把内存中类的二进制数据合并到虚拟机的运行时环境中
(1)验证:确保被加载的类的正确性。包括:
-
类文件的结构检查
:检查是否满足Java类文件的固定格式 -
语义检查
:确保类本身符合Java的语法规范 -
字节码验证
:确保字节码流可以被Java虚拟机安全的执行。字节码流是操作码组成的序列。每一个操作码后面都会跟着一个或者多个操作数。字节码检查这个步骤会检查每一个操作码是否合法。 - 二进制兼容性验证:确保相互引用的类之间是协调一致的。
(2)准备:为类的静态变量分配内存,并将其初始化为默认值
(3)解析:把类中的符号引用转化为直接引用(比如说方法的符号引用,是有方法名和相关描述符组成,在解析阶段,JVM把符号引用替换成一个指针,这个指针就是直接引用,它指向该类的该方法在方法区中的内存位置)
- 初始化:
为类的静态变量赋予正确的初始值。当静态变量的等号右边的值是一个常量表达式时,不会调用static代码块进行初始化。只有等号右边的值是一个运行时运算出来的值,才会调用static初始化。
双亲委派模型
- 当一个类加载器收到类加载请求的时候,它首先不会自己去加载这个类的信息,而是把该请求转发给父类加载器,依次向上。
所以所有的类加载请求都会被传递到父类加载器中,只有当父类加载器中无法加载到所需的类,子类加载器才会自己尝试去加载该类。当当前类加载器和所有父类加载器都无法加载该类时,抛出ClassNotFindException异常。 - 意义:
提高系统的安全性。用户自定义的类加载器不可能加载应该由父加载器加载的可靠类。(比如用户定义了一个恶意代码,自定义的类加载器首先让系统加载器去加载,系统加载器检查该代码不符合规范,于是就不继续加载了) - 定义类加载器:如果某个类加载器能够加载一个类,那么这个类加载器就叫做定义类加载器
- 初始类加载器:定义类加载器及其所有子加载器都称作初始类加载器。
- 运行时包:
- 由同一个类加载器加载并且拥有相同包名的类组成运行时包
- 只有属于同一个运行时包的类,才能访问包可见(default)的类和类成员。作用是限制用户自定义的类冒充核心类库的类去访问核心类库的包可见成员。
- 加载两份相同的class对象的情况:A和B不属于父子类加载器关系,并且各自都加载了同一个类。
- 特点:
-
全盘负责
:当一个类加载器加载一个类时,该类所依赖的其他类也会被这个类加载器加载到内存中。 -
缓存机制
:所有的Class对象都会被缓存,当程序需要使用某个Class时,类加载器先从缓存中查找,找不到,才从class文件中读取数据,转化成Class对象,存入缓存中。
类加载器:
两种类型的类加载器:
- JVM自带的类加载器(3种):
(1)根类加载器(Bootstrap):
- C++编写的,程序员无法在程序中获取该类
- 负责加载虚拟机的核心库,比如java.lang.Object
- 没有继承ClassLoader类
(2)扩展类加载器(Extension): - Java编写的,从指定目录中加载类库
- 父加载器是根类加载器
- 是ClassLoader的子类
- 如果用户把创建的jar文件放到指定目录中,也会被扩展加载器加载。
(3)系统加载器(System)或者应用加载器(App): - Java编写的
- 父加载器是扩展类加载器
- 从环境变量或者class.path中加载类
- 是用户自定义类加载的默认父加载器
- 是ClassLoader的子类
- 用户自定义的类加载器:
- Java.lang.ClassLoader类的子类
- 用户可以定制类的加载方式
- 父类加载器是系统加载器
编写步骤:
A、继承ClassLoader
B、重写findClass方法。从特定位置加载class文件,得到字节数组,然后利用defineClass把字节数组转化为Class对象
为什么要自定义类加载器?
- 可以从指定位置加载class文件,比如说从数据库、云端加载class文件
- 加密:Java代码可以被轻易的反编译,因此,如果需要对代码进行加密,那么加密以后的代码,就不能使用Java自带的ClassLoader来加载这个类了,需要自定义ClassLoader,对这个类进行解密,然后加载。
Java程序对类的执行有几种方式?:
-
主动使用
(6种情况):
- JVM必须在每个类“首次主动使用”的时候,才会初始化这些类。
- 创建类的实例
- 读写某个类或者接口的静态变量
- 调用类的静态方法
- 同过反射的API(Class.forName())获取类
- 初始化一个类的子类
- JVM启动的时候,被标明启动类的类(包含Main方法的类)
只有当程序使用的静态变量或者静态方法确实在该类中定义时,可以认为是对该类或者接口的主动使用。
-
被动使用
:
除了主动使用的6种情况,其他情况都是被动使用,都不会导致类的初始化。 - JVM规范允许类加载器在预料某个类将要被使用的时候,就预先加载它。如果该class文件缺失或者存在错误,则在程序“首次 主动使用”的时候,才报告这个错误。(Linkage Error错误)。如果这个类一直没有被程序“主动使用”,就不会报错。
类加载机制与接口:
- 当Java虚拟机初始化一个类时,不会初始化该类实现的接口。
- 在初始化一个接口时,不会初始化这个接口父接口。
- 只有当程序首次使用该接口的静态变量时,才导致该接口的初始化。
ClassLoader:
- 调用Classloader的loadClass方法去加载一个类,不是主动使用,因此不会进行类的初始化。
类的卸载:
- 由JVM自带的三种类加载器(根、扩展、系统)加载的类始终不会卸载。因为JVM始终引用这些类加载器,这些类加载器使用引用他们所加载的类,因此这些Class类对象始终是可到达的。
- 由用户自定义类加载器加载的类,是可以被卸载的。
补充:
JDK和JRK
- JDK :Java Development Kit,开发的时候用到的类包。
- JRE :Java Runtime Environment,Java运行的基础,包含运行时需要的所有类库。
图解java文件转化成机器码
JVM虚拟机先将java文件编译成class文件(字节码文件),然后再将class文件转换成所有操作系统都能运行的机器指令。
多线程
线程和进程的区别
进程是一个可执行的程序,是系统资源分配的基本单位。线程是进程内部相对独立的可执行单元,是操作系统进行任务调度的基本单位。
线程的几种状态
一共由5种状态:分别是新建、就绪、运行、阻塞和死亡状态。
创建线程的几种方法
- 继承Thread类,重写run()方法,利用Thread.start()启动线程
- 实现Runnable接口,重写run()方法,通过new Thread(Runnable a)创建线程,并调用start()方法启动线程。
- 通过callable和futuretask创建线程,实现callable接口,重写call方法,利用future对象包装callable实例,通过new Thread方法创建线程
- 通过线程池创建线程
sleep和wait方法的区别
- wait只能在sychronized中调用,属于对象级别的方法,sleep不需要,属于Thread的方法
- 调用wait方法会释放锁,sleep不会释放锁
- wait超时之后线程进入就绪状态,等待获取cpu继续执行
yield和join区别
- yield会释放cpu资源,不会释放锁,让当前线程进入就绪状态,只能使同优先级或更高优先级的线程有执行的机会
- join会释放cpu资源和锁,底层是wait()方法实现的,join会等待调用join方法的线程执行完成之后再继续执行。
ThreadLocal
ThreadLocal是什么?
- ThreadLocal是一个关于创先线程局部变量的类,主要作用是存储线程的局部变量,做到数据隔离。保存在ThreadLocal中的数据只属于当前线程,对其他线程是不可见的。在多线程环境下,可以防止自己的变量被其他线程修改而导致的数据不一致性。
- ThreadLocal设计的目的是为了能够使得当前线程拥有属于自己的变量,并不是为了解决并发或者共享变量的问题。
- 底层使用ThreadLocalMap实现,每个线程都拥有自己的ThreadLocalMap,内部是继承了WeakReference的Entry数组,包含的key为ThreadLocal,值为Object。
ThreadLocalMap的内存泄露问题
ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用时,在GC的时候,这个ThreadLocal必然会被回收,但是对应的value不会被回收掉直到线程结束才会被回收。如果当前线程一直处于运行中,那么这些Entry对象中的value就可能一直无法回收,就会发生内存泄露。
在实际开发中,会使用线程池维护线程的创建和复用,线程为了复用是不会主动结束的,那么ThreadLocal设置的value值就一直被引用,就会发生内存泄露。
创建线程池
线程池可以通过ThreadPoolExecutor来创建,构造函数如下:
/**
* Creates a new {@code ThreadPoolExecutor} with the given initial
* parameters.
*
* @param corePoolSize the number of threads to keep in the pool, even
* if they are idle, unless {@code allowCoreThreadTimeOut} is set
* @param maximumPoolSize the maximum number of threads to allow in the
* pool
* @param keepAliveTime when the number of threads is greater than
* the core, this is the maximum time that excess idle threads
* will wait for new tasks before terminating.
* @param unit the time unit for the {@code keepAliveTime} argument
* @param workQueue the queue to use for holding tasks before they are
* executed. This queue will hold only the {@code Runnable}
* tasks submitted by the {@code execute} method.
* @param threadFactory the factory to use when the executor
* creates a new thread
* @param handler the handler to use when execution is blocked
* because the thread bounds and queue capacities are reached
* @throws IllegalArgumentException if one of the following holds:<br>
* {@code corePoolSize < 0}<br>
* {@code keepAliveTime < 0}<br>
* {@code maximumPoolSize <= 0}<br>
* {@code maximumPoolSize < corePoolSize}
* @throws NullPointerException if {@code workQueue}
* or {@code threadFactory} or {@code handler} is null
*/
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
其中的参数作用分别是:
- corePoolSize: 核心线程的数量
- maximumPoolSize: 线程池中最大的线程数量
- keepAliveTime:线程池中非核心线程空闲的存活时间
- TimeUnit:线程空闲存活时间的时间单位
- workQueue:存放任务的阻塞队列
- threadFactory: 用户创建核心线程的线程工厂,可以给创建的线程自定义名字,方便查日志
- handler:线程池的饱和策略(拒绝策略),有四种类型。
几种常用的线程池
- newFixedThreadPool(固定数目线程的线程池,内部使用LinkedBlockingQueue)
- newCachedThreadPool(可缓存线程的线程池,内部使用SynchronousBlockingQueue)
- newSingleThreadPool(单线程的线程池,内部使用LinkedBlockingQueue)
- newScheduledThreadPool(定时及周期性执行的线程池,内部使用DelayQueue)
newFixedThreadPool
/**
* Creates a thread pool that reuses a fixed number of threads
* operating off a shared unbounded queue. At any point, at most
* {@code nThreads} threads will be active processing tasks.
* If additional tasks are submitted when all threads are active,
* they will wait in the queue until a thread is available.
* If any thread terminates due to a failure during execution
* prior to shutdown, a new one will take its place if needed to
* execute subsequent tasks. The threads in the pool will exist
* until it is explicitly {@link ExecutorService#shutdown shutdown}.
*
* @param nThreads the number of threads in the pool
* @return the newly created thread pool
* @throws IllegalArgumentException if {@code nThreads <= 0}
*/
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
该线程池的特点为:
- 核心线程数量=最大线程数量
- 没有非空闲时间,也就是keepAliveTime=0
- 阻塞队列为无界队列LinkedBlockingQueue
问题:使用无界队列的线程池会导致内存飙升吗?
答:会的。因为如果提交的任务很多,超过了newFixedThreadPool设置的核心线程数,会一直往无界队列里塞任务,任务累计越来越多,导致机器内存飙升,直到OOM。
使用场景:newFixedThreadPool适用于处理CPU密集型的任务,确保CPU在长期工作线程使用的情况下,尽量少的分配线程。
newCachedThreadPool
/**
* Creates a thread pool that creates new threads as needed, but
* will reuse previously constructed threads when they are
* available, and uses the provided
* ThreadFactory to create new threads when needed.
*
* @param threadFactory the factory to use when creating new threads
* @return the newly created thread pool
* @throws NullPointerException if threadFactory is null
*/
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
threadFactory);
}
该线程池的特点为:
- 核心线程数为0,最大线程数为Integer.MAX_VALUE,
- 非核心线程空闲的存活时间为60s
存在的问题:当提交任务的数量大于处理任务的数量时,每次提交一个任务必然会创建一个非核心线程,极端情况下会创建过多的线程(最大为Integer.MAX_VALUE),耗尽CPU和内存的资源。
使用场景:用于并发量大执行大量短期的小任务
newSingleThreadExecutor
/**
* Creates an Executor that uses a single worker thread operating
* off an unbounded queue, and uses the provided ThreadFactory to
* create a new thread when needed. Unlike the otherwise
* equivalent {@code newFixedThreadPool(1, threadFactory)} the
* returned executor is guaranteed not to be reconfigurable to use
* additional threads.
*
* @param threadFactory the factory to use when creating new threads
* @return the newly created single-threaded Executor
* @throws NullPointerException if threadFactory is null
*/
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory));
}
该线程池的特点:
- 核心线程数=最大线程数=1,只有一个活着的线程
- keepAliveTime=0,参数无效
- 阻塞队列是无界队列LinkedBlockingQueue
使用场景:串行执行任务的情景
newScheduledThreadPool
/**
* Creates a thread pool that can schedule commands to run after a
* given delay, or to execute periodically.
* @param corePoolSize the number of threads to keep in the pool,
* even if they are idle
* @return the newly created scheduled thread pool
* @throws IllegalArgumentException if {@code corePoolSize < 0}
*/
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
/**
* Creates a new {@code ScheduledThreadPoolExecutor} with the
* given core pool size.
*
* @param corePoolSize the number of threads to keep in the pool, even
* if they are idle, unless {@code allowCoreThreadTimeOut} is set
* @throws IllegalArgumentException if {@code corePoolSize < 0}
*/
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE,
DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS,
new DelayedWorkQueue());
}
该线程池的特点:
- 核心线程数自定义,最大线程数为Integer.MAX_VALUE
- keepAliveTime为默认10秒
- 阻塞队列为DelayWorkQueue
数据库
MySQL索引、事务、存储引擎
MVVC
MVVC
:Multiversion Concurrency Control
,翻译为多版本并发控制,其目标就是为了提高数据库在高并发场景下的性能。MVCC最大的优势:读不加锁,读写不冲突。在读多写少的场景下极大的增加了系统的并发性能。
MySQL的基本架构如下:
MySQL事务
MySQL的事务是在存储引擎层实现的,在MySQL中,最常用的就是InnoDB和MyISAM,由于MYISAM并不支持事务,所以InnoDB实现了MVCC的事务并发处理机制。先回顾一下SQL标准事务隔离级别隔离性
:
-
read uncommitted
读未提交: 一个事务还没提交时,它做的变更就能被别的事务看到。 -
read committed
读提交:一个事务提交之后,它做的变更才会被其他事务看到。 -
repeatable read
可重复读:一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。在可重复读隔离级别下,未提交变更对其他事务也是不可见的。 -
serializable
串行化 :对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。
假设有一个表mvvc_test,里边有一个字段field,SQL语句如下:
DROP TABLE IF EXISTS `mvcc_test`;
CREATE TABLE `mvcc_test`( `field` INT)ENGINE=InnoDB;
INSERT INTO `mvcc_test` VALUES(1); -- 插入一条数据
操作过程如下:
根据事务隔离级别的定义,可以推测,事务A提交前后,事务B的两次读取3和4分别读取的值:
- 若事务B的隔离级别为
read uncommitted
,事务B的两次读取都读取到了20,即修改后的值 - 若事务B的隔离级别是
read committed
,那么,事务B的操作3读取到的值为1,而4读取到的值为20,因为4时事务A已经完成了提交 - 若事务B的隔离级别是
repeatable read
或serializable
,那么操作3和4读取的值都是1。
单纯的并发带来的问题:
Lost Update
更新丢失: 多个事务对同一行数据进行读取初值更新时,由于每个事务对其他事务都未感知,会造成最后的更新覆盖了其他事务所做的更新。dirty read
脏读: 事务一个正在对一条记录进行修改,在完成并提交前事务二也来读取该条记录,事务二读取了事务一修改但未提交的数据,如果事务一回滚,那么事务二读取到的数据就成了“脏”数据。non-repeatable read
不可重复读: 某个事务在读取某些数据后的某个时间再次读取之前读取过的数据,发现读出的数据已经发生了改变或者删除,这种现象称为“不可重复读”phantom read
幻读: 某个事务按相同的查询条件重新读取以前检索过的数据,发现其他事务插入了满足查询条件的新数据,这种现象称为“幻读”
不可重复读与幻读的现象是比较接近的,也有人直接就说幻读就是不可重复读,区别在于:不可重复读针对的是值的不同,幻读指的是数据条数的不同。同样的对于幻读,单纯的MVCC机制并不能解决幻读问题,InnoDB也是通过加间隙锁来防止幻读。
从本质上来说,事务隔离级别就是系统并发能力和数据安全性间的妥协。解决并发带来的问题,最通常的就是加锁,但锁对于性能也是腰斩性的,所以MVCC就显得十分重要了。 在不同的隔离级别下,数据库通过 MVCC 和隔离级别,让事务之间并行操作遵循了某种规则,来保证单个事务内前后数据的一致性。
InnoDB 下的 MVCC 实现原理
在InnoDB中MVCC的实现通过两个重要的字段进行连接:DB_TRX_ID
和DB_ROLL_PT
,在多个事务并行操作某行数据的情况下,不同事务对该行数据的UPDATE会产生多个版本,数据库通过DB_TRX_ID来标记版本,然后用DB_ROLL_PT回滚指针将这些版本以先后顺序连接成一条 Undo Log 链。
对于一个没有指定PRIMARY KEY的表,每一条记录的组织大致如下:
- DB_TRX_ID: 事务id,6byte,每处理一个事务,值自动加一。
InnoDB中每个事务有一个唯一的事务ID叫做transaction id
。在事务开始时向InnoDB事务系统申请得到,是按申请顺序严格递增的;
每行数据是有多个版本的,每次事务更新数据时都会生成一个新的数据版本,并且把transaction id赋值给这个数据行的DB_TRX_ID - DB_ROLL_PT: 回滚指针,7byte,指向当前记录的
ROLLBACK SEGMENT
的undolog
记录,通过这个指针获得之前版本的数据。该行记录上所有旧版本在 undolog 中都通过链表的形式组织。 - DB_ROW_ID(隐含id,6byte,由innodb自动产生),我们可能听说过InnoDB下聚簇索引B+Tree的构造规则:
如果声明了主键,InnoDB以用户指定的主键构建B+Tree,如果未声明主键,InnoDB 会自动生成一个隐藏主键,说的就是DB_ROW_ID。另外,每条记录的头信息(record header)里都有一个专门的bit(deleted_flag)来表示当前记录是否已经被删除
通过上述两事务的操作流程,即UPDATE(即操作2)来举例Undo log链的构建(假设第一行数据DB_ROW_ID=1):
- 事务A对DB_ROW_ID=1这一行加排它锁
- 将修改行原本的值拷贝到Undo log中
- 修改目标值产生一个新版本,将
DB_TRX_ID
设为当前事务ID即100,将DB_ROLL_PT
指向拷贝到Undo log中的旧版本记录 - 记录redo log, binlog
最终生成的Undo log链如下图所示:
相比与UPDATE,INSERT和DELETE都比较简单: - INSERT: 产生一条新的记录,该记录的
DB_TRX_ID
为当前事务ID - DELETE: 特殊的UPDATE,在
DB_TRX_ID
上记录下当前事务的ID,同时将delete_flag设为true,在执行commit时才进行删除操作
MVCC的规则大概就是以上所述,那么它是如何实现高并发下RC和RR的隔离性呢,这就是在MVCC机制下基于生成的Undo log链和一致性视图ReadView来实现的。
一致性视图的生成 ReadView
要实现read committed
在另一个事务提交之后其他事务可见和repeatable read
在一个事务中SELECT操作一致,就是依靠ReadView
,对于read uncommitted
,直接读取最新值即可,而serializable
采用加锁
的策略通过牺牲并发能力而保证数据安全,因此只有RC
和RR
这两个级别需要在MVCC
机制下通过ReadView
来实现。
在read committed
级别下,readview
会在事务中的每一个SELECT语句查询发送前生成(也可以在声明事务时显式声明START TRANSACTION WITH CONSISTENT SNAPSHOT
),因此每次SELECT都可以获取到当前已提交事务和自己修改的最新版本。
而在repeatable read
级别下,每个事务只会在第一个SELECT语句查询发送前或显式声明处生成,其他查询操作都会基于这个ReadView,这样就保证了一个事务中的多次查询结果都是相同的,因为它们都是基于同一个ReadView下进行MVCC机制的查询操作。
InnoDB
为每一个事务构造了一个数组m_ids
用于保存一致性视图生成瞬间当前所有活跃事务
(开始但未提交事务)的ID,将数组中事务ID最小值记为低水位m_up_limit_id
,当前系统中已创建事务ID最大值+1记为高水位m_low_limit_id
,构成如图所示:
一致性视图下查询操作的流程如下:
- 当查询发生时根据以上条件生成ReadView,该查询操作遍历
Undo log
链,根据当前被访问版本(可以理解为Undo log链中每一个记录即一个版本,遍历都是从最新版本向老版本遍历)的DB_TRX_ID
,如果DB_TRX_ID
小于m_up_limit_id
,则该版本在ReadView生成前就已经完成提交,该版本可以被当前事务访问。DB_TRX_ID在绿色范围内的可以被访问 - 若被访问版本的1DB_TRX_ID1大于1m_up_limit_id1,说明该版本在ReadView生成之后才生成,因此该版本不能被访问,根据当前版本指向上一版本的指针1DB_ROLL_PT1访问上一个版本,继续判断。DB_TRX_ID在蓝色范围内的都不允许被访问
- 若被访问版本的
DB_TRX_ID
在[m_up_limit_id, m_low_limit_id)
区间内,则判断DB_TRX_ID
是否等于当前事务ID,等于则证明是当前事务做的修改,可以被访问,否则不可被访问, 继续向上寻找。只有DB_TRX_ID等于当前事务ID才允许访问橙色范围内的版本 - 最后,还要确保满足以上要求的可访问版本的数据的
delete_flag
不为true,否则查询到的就会是删除的数据。
所以以上总结就是只有当前事务修改的未commit版本和所有已提交事务版本允许被访问。
一致性读和当前读
以如下SQL查询语句为例:
DROP TABLE IF EXISTS `mvccs`;
CREATE TABLE `mvccs`( `field` INT)ENGINE=InnoDB;
INSERT INTO `mvccs` VALUES(1); -- 插入一条数据
假设在所有事务开始前当前有一个活跃事务10,且这三个事务期间没有其他并发事务:
- 在操作1开始SELECT语句时,需要创建一致性视图,此时当前事务的一致性视图为[10, 100, 200,301), 事务100开始查询Undo log链,第一个查询到的版本为为事务200的操作4的更新操作, DB_TRX_ID在m_ids数组但并不等于当前事务ID, 不可被访问;
- 向上查询下一个即事务300在操作6时生成的版本,小于高水位m_up_limit_id,且不在m_ids中,处于已提交状态,因此可被访问;
- 综上在RR和RC下得到操作1查询的结果都是2
那么操作5查询到的field的值是多少呢?
- 在RR下,我们可以明确操作2和操作3查询field的值都是1,在RC下操作2为1,操作3的值为2,那么操作5的值呢?
- 答案在RR和RC下都是是3,UPDATE操作都是读取当前读(current read)数据进行更新的,而不是一致性视图ReadView,因为如果读取的是ReadView,那么事务300的操作会丢失。当前读会读取记录中的最新数据,从而解决以上情形下的并发更新丢失问题。