悲观锁与乐观锁
悲观锁
总是假设最坏的情况,线程a每次去获取或更新数据的时候,都会觉得别的线程也正在修改这个数据,为了避免自己的更新操作丢失,线程a会尝试获取此数据的锁,线程a获取到之后,才能对此数据进行一些更新操作。在此期间,别的线程无法更新,只能等到线程a释放锁之后,才能进行更新。
之所以叫做悲观锁,是因为这是一种对数据的修改抱有悲观态度的并发控制方式。我们一般认为数据被并发修改的概率比较大,所以需要在修改之前先加锁。
悲观并发控制,实际上是一种“先取锁再访问”的保守策略。
synchronized就是对悲观锁的一种实现。
乐观锁
乐观锁假设数据一般不会造成冲突,所以在拿数据的时候不会去加锁,但是会在更新的时候判断此期间内有没有别的线程修改过数据。
CAS机制就是对乐观锁的一种实现。
乐观锁的实现——CAS
CAS操作一般包含3个参数,期望值、内存值、新值。如果期望值与内存值相等,则用新值去更新这个内存值。如果不相等,则可以再次进行比较,一直到成功为止。
CAS是一种非阻塞的算法,线程在更新失败时,不需要挂起,因此省去了大量线程上下文切换的开销。
java使用Unsafe类来支持CAS操作,对Unsafe类不了解的同学可以先参考我的另外一篇文章JUC基石——Unsafe类。
我们用java代码来简要模拟CAS的过程:
/** * @param expect 期望值 * @param update 新值 * @return */ public int cas(int expect, int update) { //更新失败就一直进行忙循环 while (true) { //get方法从内存中获取最新的值 int memory = get(); if (memory == expect) { //set方法将内存中的值设置为新值 set(update); return update; } } }
当然这只是一个模拟,实际cas操作将会用到底层的系统指令,这些指令将会保证整个cas操作具有原子性,关于这些指令,可能要另开篇幅讲解。
悲观锁的实现——synchronized
synchronized是悲观锁的典型实现,有关它的用法,可以参考我的这篇文章浅说Synchronized,早期的synchronized十分笨重,所幸在1.6之后进行了大量的优化,锁性能提升了很多,关于synchronized的优化,可以参考我的这篇文章Synchronized的优化。
CAS的缺陷——ABA问题
假设有这样的一种情况,x的内存值首先是A,线程1读取到了A,之后忙别的事情了,该值在之后被线程2改成了B,接着又被线程3改成了A,线程1此时进行CAS操作,发现内存值还是A,于是进行了更新操作。但是这个A已经不是原来的A了,或者说不是之前那个版本的A了。
解决这种缺陷,可以使用带版本号或时间戳的CAS,A值每次被更新后,版本号加1,或者更新时间戳。此时内存值与期望值相等,但却不是线程期望的版本号。
此时的A→B→A,就变成了A(version=1)→B(version=2)→A(version=3)。当使用带版本号的CAS后,就可以避免ABA问题。
CAS与synchronized适用场景
线程冲突比较小时,CAS进行自旋操作,synchronized升级为轻量级锁,也是在自旋,两者的效率差不多。
线程冲突严重时,CAS绝大部分的自旋操作将大量浪费CPU的时间片,此时synchronized升级为重量级锁,但在这种情况下,synchronized的效率远高于CAS。(因为在线程冲突严重时,synchronized已经意识到轻量级锁的自旋操作效率低下,主动升级为重量级锁,所以这里的忙循环的开销远远大于线程切换的开销)。
JAVA中的CAS操作
AtomicInteger实现了CAS,可以原子性地更新一个int类型数据,其实底层也是调用Unsafe类。但是如果要一次原子性地更新多个变量,可以使用AtomicReference,当然这个存在上述的ABA问题,这时可以使用带版本号机制的CAS实现类——AtomicStampedReference,该类使用了一个stamp字段来表示版本号,代码如下图所示:
数据库中的CAS操作
数据库中的乐观锁机制不需要借助表锁、行锁等,以修改库存为例,乐观锁实现如下:
update goods set quantity=99 where id=1 and quantity = 100;
这个情景比较简单,暂不考虑ABA问题。
以上SQL其实还是有一定的问题的,就是一旦高并发的时候,就只有一个线程可以修改成功,那么就会存在大量的失败。所以,需要减小乐观锁的粒度。
有一条比较好的建议,可以减小乐观锁力度,最大程度的提升吞吐率,提高并发能力!如下:
update goods set quantity=quantity - 1 where id = 1 and quantity - 1 > 0
将quantity=100转化成了quantity - 1 > 0,大大减少了乐观锁的力度,效率得到很大的提升。
JVM中的CAS操作
Java调用new object()会创建一个对象,这个对象会被分配到JVM的堆中。那么这个对象到底是怎么在堆中保存的呢?
首先,new object()执行的时候,这个对象需要多大的空间,其实是已经确定的,因为java中的各种数据类型,占用多大的空间都是固定的。怎么去确定对象大小,可以参考我的这篇文章对象的内存布局,怎样确定对象的大小。那么接下来的工作就是在堆中找出那么一块空间用于存放这个对象。
在单线程的情况下,一般有两种分配策略:
指针碰撞:这种一般适用于内存是绝对规整的(内存是否规整取决于内存回收策略)。用过的内存放在一边,空闲的内内存放在另外一边,之间有一个分界指针,分配空间的工作只是将分界指针向空闲内存一侧移动对象大小的距离即可。
空闲列表:这种适用于内存非规整的情况,这种情况下JVM会维护一个内存列表,记录哪些内存区域是空闲的,大小是多少。给对象分配空间的时候去空闲列表里查询到合适的区域然后进行分配即可。
然而,对象的创建工作是很频繁的,为了保证效率,JVM可以并发地给对象分配内存空间。由于分配内存的时候不是原子性的操作,至少需要以下几步:查找空闲列表、分配内存、修改空闲列表等等,这是不安全的。解决并发时的安全问题也有两种策略:
CAS:实际上虚拟机采用CAS配合上失败重试的方式保证更新操作的原子性,原理和上面讲的一样。
TLAB:如果使用CAS其实对性能还是会有影响的,所以JVM又提出了一种更高级的优化策略:每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区(TLAB),线程内部需要分配内存时直接在TLAB上分配就行,避免了线程冲突。只有当缓冲区的内存用光需要重新分配内存的时候才会进行CAS操作分配更大的内存空间。 虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来进行配置(jdk5及以后的版本默认是启用TLAB的)。