什么是悲观锁
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改)。
所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
像 Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。
什么是乐观锁
乐观锁的思想是:总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的数据是否被其他线程修改了。
比如Java中的atomic包下面的原子类,就是使用了乐观锁的思想。
高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试(悲观锁的开销是固定的),这样同样会非常影响性能,导致 CPU 飙升。
不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder以空间换时间的方式就解决了这个问题。
理论上来说:
- 悲观锁通常多用于写比较多的情况下(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如
LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。 - 乐观锁通常多于写比较少的情况下(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考
java.util.concurrent.atomic包下面的原子变量类)。
如何实现乐观锁
乐观锁一般会使用版本号机制或者CAS算法来实现,一般使用CAS的多一些。
版本号机制
一般是在数据表中加上一个数据版本号字段,表示数据被修改的次数。
- 当数据被修改时,version值会加一。
- 当线程A要更新数据值时,在读取数据的同时也会读取version值。
- 在提交更新时,若刚才读取到的version值为当前数据库的version值相等时才更新;否则重试更新操作,直到更新成功。
CAS算法
思想就是:用一个预期值和要更新的变量值进行比较,两者相等才会更新。
CAS是一个原子操作,依赖于CPU的原子指令。操作一旦开始,就不能被打断,直到操作完成。
CAS涉及到三个操作数:
- V:要更新的变量值
- E:预期值
- N:要写入的新值
当且仅当V的值等于E时,才能将V的值更新为N。如果不等,说明有其他线程更新了V,则当前线程放弃更新。失败的线程不会被挂起,允许再次尝试,也允许失败的线程放弃操作。
排他锁与共享锁
- 排他锁也叫独占锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排他锁之后(即线程T获取到数据A的锁之后),则其他线程不能再对A加任何类型的锁。获得排他锁的线程既能读取数据也能修改数据。
synchronized和ReentrantLock就是独占锁。 - 共享锁,是指该锁可同时被多个线程所持有。如果线程T对数据A加上共享锁之后,其他线程只能对A再加共享锁,不能加排他锁。获得共享锁的线程只能读取数据,不能修改数据。
ReentrantReadWriteLock的读锁是可以被共享的,但是它的写锁确每次只能被独占。
公平锁与非公平锁
- 公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
- 非公平锁:每个线程获取锁的顺序是随机的,并不会遵循先来先得的规则,所有线程会竞争获取锁。
在 Java 语言中,锁 synchronized 和 ReentrantLock 默认都是非公平锁,当然我们在创建 ReentrantLock 时,可以手动指定其为公平锁,但 synchronized 只能为非公平锁
执行流程
公平锁执行流程
获取锁时,先将线程自己添加到等待队列的队尾并休眠,当某线程用完锁之后,会去唤醒等待队列中队首的线程尝试去获取锁,锁的使用顺序也就是队列中的先后顺序,在整个过程中,线程会从运行状态切换到休眠状态,再从休眠状态恢复成运行状态,但线程每次休眠和恢复都需要从用户态转换成内核态,而这个状态的转换是比较慢的,所以公平锁的执行速度会比较慢。
非公平锁执行流程
当线程获取锁时,会先通过 CAS 尝试获取锁,如果获取成功就直接拥有锁,如果获取锁失败才会进入等待队列,等待下次尝试获取锁。这样做的好处是,获取锁不用遵循先到先得的规则,从而避免了线程休眠和恢复的操作,这样就加速了程序的执行效率。
优缺点分析
公平锁的优点是按序平均分配锁资源,不会出现线程饿死的情况,它的缺点是按序唤醒线程的开销大,执行性能不高。
非公平锁的优点是执行效率高,谁先获取到锁,锁就属于谁,不会“按资排辈”以及顺序唤醒,但缺点是资源分配随机性强,可能会出现线程饿死的情况。
读锁和写锁
读写锁既是互斥锁,又是共享锁,read模式是共享,write是互斥(排它锁)的。
线程持有读锁还能获取写锁吗?
- 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
- 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。
读写锁的源码分析,推荐阅读 这篇文章,写的很不错。
读锁为什么不能升级为写锁?
写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。
另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。
自旋锁
自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
偏向锁/轻量级锁/重量级锁
锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁(锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。锁的状态是通过对象监视器在对象头中的字段来表明的。JDK 1.6中默认是开启偏向锁和轻量级锁的。
偏向锁
一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价
轻量级
轻量级锁:当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁
当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
一旦对象锁升级为重级锁后,后续线程对该对象的操作都将是加锁为重量级锁
死锁
两个线程都需要等待对方释放锁,才能继续进行,就会导致进入死锁状态。