众所周知,java里的锁可以分为两种:乐观锁和悲观锁,首先介绍一下这两个概念。
1、乐观锁、悲观锁概念
1.1 悲观锁
总是假设最坏的情况,每次去读取数据时总认为别人会修改,因此每次读取数据时都会上锁,其他线程想访问该数据时只能阻塞,直到这个线程释放了锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock就是悲观锁思想的实现。
1.2 乐观锁
总是假设最好的情况,每次去读取数据时总认为别人不会修改,因此每次读取数据时不会上锁,但是在做写操作时会判断一下从读取这个数据到真正执行写操作前有没有其他线程去更新这个数据。乐观锁的实现常用的是版本号机制和CAS算法实现,Atomic原子类就是用CAS机制实现的。乐观锁适用于多读的应用类型,这样可以提高吞吐量。
乐观锁和悲观锁的使用场景:乐观锁适用于“多读”的场景,悲观锁适用于“多写”的场景。
悲观锁之前synchronized关键字和Lock里介绍的比较多了,这里重点介绍一下乐观锁。
2、乐观锁
乐观锁有两种实现方式:版本号机制和CAS机制,下面分别介绍。
2.1 版本号机制
在数据库表中加一个版本号字段:version_id,当数据被修改时,版本号会+1。当从数据库读取数据时,记录此时的版本号version_id1,在对数据进行写操作时,再从当前数据库里读取版本号:version_id2,若此时version_id1 == version_id2,即认为从读到这个数据到更新这个数据之间该数据没有被更新,此时更新数据;反之,重试更新操作直到成功。
2.2 CAS机制
(1)CAS机制介绍
全称Compare And Swap,是一种经典的无锁算法实现临界变量的同步,也就是没有线程被阻塞的情况下实现变量同步,因此也是非阻塞同步的一种方式。CAS算法涉及三个操作数:
- 临界变量内存里的值V
- 旧的预期值A(expect)
- 要更新的值B(upgrade)
更新之前,会将旧的预期值A和内存地址的值V进行比较,当二者相等时,CAS通过原子方式用新值B来更新V的值,否则会进行自旋操作(即不断重试),注意比较和替换两个操作是原子操作。
这里说明一下自旋操作,CAS更新变量其实有两个步骤:
- 计算内存地址V、预期值A和更新值B;
- 比较V和A,相等则将变量更新为B,否则进行自选,重新走一遍这两个步骤;
举个例子,线程1和线程2同时修改某个临界变量,线程1进行到了上面的步骤1,此时线程1阻塞;线程2进行步骤1和步骤2,之后线程1被唤醒。唤醒后的线程1执行步骤2,发现线程1的步骤1计算得到的V和A已经不相等了,因为线程2已经把内存中的值更新了,此时线程1会进行自旋(重试),线程1会重新执行步骤1,重新计算一遍这三个值,把V和A的值更新为相同的值,此时如果没有其他线程再“打搅”线程1(在线程1执行步骤2之前又把内存里的值更新了),线程1会顺利地执行步骤2更新变量值为B,如果有被打搅,线程1继续自旋(重新计算,然后比较,最后更新或者继续自旋)。
上面这个例子也是描述CAS机制是如何保证不加锁的前提下实现临界变量同步的过程,更详细的图文讲解请参考链接1。
(2)Atomic原子类的CAS实现
以AtomicInteger类的自增函数incrementAndGet来说,incrementAndGet()方法是AtmoicInteger原子类提供的原子性的自增方法,其源码如下:
public final int incrementAndGet() {
return U.getAndAddInt(this, VALUE, 1) + 1;
}
这里的U是通过Unsafe类的静态方法获得的一个Unsafe类对象,atomic底层的CAS实现也是由这个Unsafe类提供的,如下:
private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
进去incrementAndGet里的getAndAddInt方法,这个方法是在Unsafe类里提供的,如下:
@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
先说明一下getAndAddInt方法的入参:
- Object o:具体的内存地址;
- long offset:临界变量在内存地址里的偏移量(index)
- int delta:要add的值。
这里的o和offset结合起来就是临界变量的内存里的值V。
里面的do-while循环就是对应的CAS机制里的自旋操作。getIntVolatile方法获得临界变量在内存里的值,方法名有个volatile猜想底层的实现应该类似volatile机制保证从内存里拿到的值是当前最新的值;while语句判断,表达式里有个weakCompareAndSetInt方法,源码如下:
@HotSpotIntrinsicCandidate
public final boolean weakCompareAndSetInt(Object o, long offset,
int expected,
int x) {
return compareAndSetInt(o, offset, expected, x);
}
又是一环套一环,点进去这个compareAndSetInt方法,源码如下:
@HotSpotIntrinsicCandidate
public final native boolean compareAndSetInt(Object o, long offset,
int expected,
int x);
终于看到了native关键字,代表这个方法可以不用再看底层实现了,该方法做的是根据Object o和long offset拿到临界变量的在内存里的值,跟expected(就是前面讲的预期值A)比较,如果两个值相等,把临界变量内存里的值更新为x并返回true,否则什么也不改变返回false。
weakCompareAndSetInt方法是干什么也知道了,入参里v对应前面讲CAS机制时的预期值A, v + delta对应更新值B。
再回到getAndAddInt方法里,注意当while里的表达式为true跳出循环后,return的是执行更新操作前最后一次获取到的临界变量内存里的值,并不是更新后的值,即getAndAddInt方法返回的是更新前最后一次从内存地址里获取到的值。因此AtomicInteger的incrementAndGet方法里,调用getAndAddInt之后,还要再加1,如下:
public final int incrementAndGet() {
return U.getAndAddInt(this, VALUE, 1) + 1;
}
(3)CAS机制的缺点
1、会带来ABA问题,即如果一变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,它中间有可能也进行了修改变为了B值,只是在执行写操作前又改回了A,那CAS操作就会误认为它从来没有被修改过,这个问题被称为CAS操作的"ABA"问题。
如何解决ABA问题?我们可以使用JDK的AtomicStampedReference和AtomicMarkableReference类。简单来说就是为这个对象提供了一个版本号,并且当对象被修改了,对应的版本号是自动加1的,那么A-B-A 就会变成1A-2B-3A,其中1、2、3是对应的版本号。
2、CAS机制如果expect和V长时间都不一致,会进行自旋操作(即不断的重试),这会给CPU带来非常大的执行开销。
3、CAS机制只能保证一个临界变量的原子操作 ,当操作涉及多个共享变量时 CAS 无效。
(4)CAS和synchronized的使用场景
1、对于资源竞争情况较小的场景,使用synchronized这种“重量级锁”会带来CPU的额外开销,而CAS基于硬件实现,操作自旋几率较少,因此可以获得更高的性能。
2、对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。