HashMap底层实现
首先来看HashMap的几个重要参数。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 初始容量是16
/**
* 最大容量是2的30次幂
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认负载因子0.75
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 链表转红黑树的临界值
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 红黑树转链表的临界值
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 最小链表树化的容量,小于该值则先考虑的是扩容。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
用语言描述 HashMap 的put,get,resize过程
JDK1.8有什么改进?在HashMap上的改进是什么?
JDK 1.8 以前 HashMap 的实现是 数组+链表,1.8之后加入了红黑树这个数据结构。
当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。
针对这种情况,JDK 1.8 中引入了 红黑树(查找时间复杂度为 O(logn))来优化这个问题.
HashMap 是不是线程安全的,线程安全的相关类有哪些?
线程不安全,安全的可以考虑ConcurrentHashMap这个类。
HashMap的负载因子
可以自己进行设置,但是默认的是0.75.
高并发系统往往会存在数据不一致的问题。例如某购物网站发布的秒杀商品,在同一时间点,可能存在几万甚至上百万的用户访问,这就是一个典型的高并发场景。
在高并发场景,多个线程同时享有并访问数据。由于线程每一步的完成顺序不一样,会存在数据不一致的问题。
当前互联网主要通过悲观锁和乐观锁来解决高并发场景下的数据不一致问题。
悲观锁
悲观锁是一种利用数据库内部机制提供的锁的方法,也就是对更新的数据加锁。这样在并发期间一旦有一个事务持有了数据库记录的锁,其他的线程将不能再对数据进行更新。从字面上来看,悲观锁持一种悲观的态度,认为在访问数据时一定会与其他线程发生冲突。因此悲观锁的机制,就是在每次访问数据时都一定会拿到锁并对数据进行上锁,这样一来,当别的线程也要访问该数据时就会阻塞直到它拿到锁。因为在同一时间,只有一个线程可以独占锁,所以悲观锁有时候也称独占锁或阻塞锁。
乐观锁
与悲观锁不同的是,乐观锁(也称非阻塞锁)是一种不会阻塞其他线程并发的机制,它不是通过数据库的锁实现的,因而不会引起线程的频繁阻塞和恢复。乐观锁持乐观态度,认为在访问数据时不会与其他线程发生冲突,只是在更新数据时检查数据。
2.1 CAS 原理
乐观锁使用的是CAS原理。在CAS原理中,对于多个线程共同访问的数据,维护一个旧值(Old Value),当更新数据时,先比较当前获取的值与旧值是否一致。如果一致,则更新,若不一致,则认为数据已经被其他线程修改,放弃更新或者重试。CAS原理的流程图如下所示:
在这里插入图片描述
2.2 ABA问题
CAS原理解决了悲观锁带来的线程频繁切换状态的问题,但是CAS同样存在一个问题,ABA问题。
时刻 | 线程1 | 线程2 | 备注 |
T0 | -- | -- | 初始化X=A |
T1 | 读入X=A | -- | -- |
T2 | -- | 读入X=A | -- |
T3 | 处理线程1的业务逻辑 | X=B | 修改共享变量为B |
T4 | 处理线程2业务逻辑第一段 | 此时线程1在X=B的情况下运行逻辑 | |
T5 | X=A | 还原变量为A | |
T6 | 因为判断X=A,所以执行更新操作 | 处理线程2业务逻辑第二段 | 此时线程1无法知道线程2是否修改过X,引发逻辑错误 |
T7 | -- | 更新数据 | -- |
如图所示,由于X在线程2中的值改变的过程为A->B->A,因此称这类问题为ABA问题。导致ABA问题发生的原因是业务逻辑存在回退的可能性(如图中线程2将X的值从A变为B,后又变为A),解决ABA问题的方法是引入版本号(version),每当修改数据时,版本号递增,不会回退。用版本号消除ABA问题如下图所示。
时刻 | 线程1 | 线程2 | 备注 |
T0 | -- | -- | 初始化X=A,version=0 |
T1 | 读入X=A | -- | 线程1旧值:version=0 |
T2 | -- | 读入X=A | 线程2旧值:version=0 |
T3 | 处理线程1的业务逻辑 | X=B | 修改共享变量为B ,version=1 |
T4 | 处理线程2业务逻辑第一段 | -- | |
T5 | X=A | 还原变量为A,version=2 | |
T6 | 判断version==0,由于线程2两次更新数据,导致数据version=2,所以不执行更新操作 | 处理线程2业务逻辑第二段 | 此时线程1知道旧值version和当前version不一致,将不执行更新操作 |
T7 | -- | 更新数据 | -- |
使用version的乐观锁实践代码:
update tb_product set stock=stock-1,version=version+1 where id=#{id} and version=#{version}
在修改库存时,增加了对版本号的判断version=#{version}。并且,在每次扣减库存时,版本号增加version=version+1,保证了版本号记录了库存的更新记录,从而避免ABA问题。
2.3 重入机制
如上所示,当使用版本号来实现乐观锁时,当版本号与先前获得的不一致时,线程会放弃修改数据并重新尝试访问数据即重入。过多的重入会造成大量的SQL执行,因此目流行的重入会加入两种限制:
(1)按照时间戳的重入:在一定时间戳内,不成功则一直重入到成功为止直至超过时间戳;
(2)按照次数重入:即重入的次数又上限,当达到上限次数还未成功时,则不再重入,请求失败。
总结
上述介绍了悲观锁的数据上锁机制,乐观锁的旧值->旧值+版本号->旧值+版本号+重入机制三种递进的方式,总的来说,悲观锁通过对共享数据的上锁来保持数据的一致性,而乐观锁则解决了悲观锁的线程阻塞问题。
乐观锁适合用于写操作比较少的场景(多读场景),悲观锁适用于并发量不大且不允许脏读(脏读指的是读取的数据是错误的,无效的)的场景(多写场景)。概括来说,若对系统的数据一致性要求高,则使用悲观锁;若对系统的并发性能要求高,则使用乐观锁。