Java锁
0x00 简介
0x01 乐观锁与悲观锁
对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized
关键字和Lock
的实现类都是悲观锁。悲观锁适用于写操作多的过程,先加锁可以保证写操作时的数据正确。
而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就是通过CAS自旋实现的。
乐观锁的主要实现方式为CAS技术,CAS即为Compare And Swap(比较与交换)。这是一种无锁算法,在不使用锁的情况下,实现多线程之间的变量同步。诸如AtomicInteger
之类的原子类就是通过CAS实现的乐观锁。其主要涉及3个操作数,需要读写的内存值V,进行比较的值A和要写入的新值B。当且仅当V==A
的时候,才会将新值B写入V,否则不执行任何操作。这是一个不断重复尝试的操作,如果操作不成功的话,会导致其一直自旋,给CPU带来较大开销。自旋就是一个循环等待的这么一个过程,不断等待直至成功,等待的过程即忙等待(busy-waiting)。
CAS会引入ABA问题,假设这样一个场景,线程A使用CAS读到值为10,在准备修改新值之前,线程B将这个值修改为了20,然后线程C又重新将这个值修改为了10,此时该值又恢复成为旧值。这样的话,我们就无法正确地判断这个变量是否已经被修改过。如下图:
为了解决ABA问题,可以使用AtomicStampedReference
原子类,这是一个带有时间戳的对象引用,每次修改后,其不仅会设置新值而且会记录更改时间,当使用其设置对象值时,对象值以及时间戳都必须满足期望值才能写入成功。
0x02 自旋锁和适应性自旋锁
阻塞和唤醒一个线程本身需要耗费大量的CPU时间来完成。在很多场景中,同步资源锁定的时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的时间可能会使系统得不偿失。为了让当前线程稍等一下,我们可以让当前线程自旋,如果在自旋完成后,前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免了切换线程的开销。
自旋锁适用于锁被占用时间很短的情况,如果锁被占用的时间较长,自旋的线程就会白白占用处理器资源。
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
0x03 偏向锁、轻量级锁和重量级锁
偏向锁偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,那么持有偏向锁的线程无需再进行同步。很明显,当锁的竞争情况很少出现时,偏向锁就能提高性能。
轻量级锁是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
重量级锁是将除了拥有锁的线程以外的线程都阻塞。
0x04 公平锁和非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
0x05 可重入锁和非可重入锁
可重入锁又名递归锁,指同一个线程在外层方法获取锁的时候,外层方法调用同样加锁的内层方法时会自动获取锁,当然前提是锁住的是同一个对象或者class
。Java中的ReentrantLock
和synchronized
都是可重入锁。可重入锁的一个优点是在一定程度上可以避免死锁。
1 | public class Widget { |
在上面的代码中,类中的两个方法都是被内置锁synchronized
修饰的,doSomething()
方法中调用doOthers()
方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()
时可以直接获得当前对象的锁,进入doOthers()
进行操作。
如果是一个不可重入锁,那么当前线程在调用doOthers()
之前需要将执行doSomething()
时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。
0x06 独享锁和共享锁
独享锁即排它锁,是指该锁一次只能被一个线程所持有,如果线程T对数据A加上排它锁后,则其它线程不能再对A加任何类型的锁。获得排它锁的线程既能读数据又能写数据。synchronized
即为排它锁。
共享锁指的是该锁可以被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。而且持有共享锁的线程只能读数据但不能写数据。
在ReentrantReadWriteLock
里面,读锁是共享锁,写锁是排它锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock
的并发性相比一般的互斥锁有了很大提升。