Java的锁Java根据不同的特性来对锁进行分类,大概有以下分类方式。这里主要讨论乐观锁和悲观锁以及在Java中对应的实现。对于同一个数据的并发操作,悲观锁认为自己在使用数据时,一定会有其它线程来修改
Java的锁
Java根据不同的特性来对锁进行分类,大概有以下分类方式。
这里主要讨论乐观锁和悲观锁以及在Java中对应的实现。对于同一个数据的并发操作,悲观锁认为自己在使用数据时,一定会有其它线程来修改数据,所以在每次操作数据前都会加上一个锁,以确保没有其它线程来修改数据。Java中的synchronized锁和lock锁都是悲观锁。
而乐观锁每次都认为不会有其它线程来修改数据,所以在操作数据时不会上锁,而是在修改数据时去判断有没有其它线程修改了这个数据,如果没有被修改,则更新成功,如果已经被其它线程修改,则重新尝试或失败。Java中最常用的就是通过CAS算法来实现无锁并发编程。
- 悲观锁适合写多读少的场景,因为先加锁能保证写操作的正确性。
- 乐观锁适合读多写少的场景,因为读操作一般并不需要加锁(没有修改数据),所以乐观锁的无锁特性能使读性能有很大的提升(减少了加锁等待的时间)。
悲观锁
Synchroniezd锁
synchronized是一种互斥锁,也就是悲观锁,每次只允许一个线程进入synchronized修饰的方法或代码块中。synchronized锁是可重入的,即一个线程可以多次获取同一个对象或类的锁。
synchronized通过使用内置锁,对变量进行同步,来保证线程操作的原子性、有序性、可见性,可以确保多线程下的操作安全。
synchronized锁有三种使用方式,分别是对对象加锁(修饰普通方法,锁的是当前类的对象)、对代码块加锁(锁的是非当前类对象)、对类加锁(修饰静态方法,锁的是当前类)。更多:synchronized锁
下面是用synchronized锁,用N个线程循环打印0~M个数字。
public class SynchronizedTest implements Runnable { // 定义一个对象用来保持锁 private static final Object LOCK = new Object(); // 当前线程 private int threadNum; // 线程总数 private int threadSum; // 当前数字,从0开始打印 private static int current = 0; // 要打印的最大值 private int max; public SynchronizedTest(int threadNum, int threadSum, int max) { this.threadNum = threadNum; this.threadSum = threadSum; this.max = max; } @Override public void run() { // 实现N个线程循环打印数字 while (true) { // 对代码块加锁,保证每次只有一个线程进入代码块 synchronized (LOCK) { // 当前值 / 线程总数 = 当前线程 // 这里一定要用while,而不能用if。因为当线程被唤醒时,监视条件可能还没有满足(线程唤醒后是从wait后面开始执行)。 while (current % threadSum != threadNum) { // 打印完了,跳出循环 if (current >= max) { break; } // 不满足打印条件,则让出锁,进入等待队列 try { LOCK.wait(); } catch (Exception e) { e.printStackTrace(); } } // 这里还要做一次判断 if (current >= max) { break; } System.out.println(Thread.currentThread().getName() + " 打印 " + current); ++current; // 当前线程打印完了,通知所有等待的线程进入阻塞队列,然后一起去争抢锁 LOCK.notifyAll(); } } } public static void main(String[] args) { // 开启N个线程 int N = 3; // 打印M个数字 int M = 15; for (int i = 0; i < N; ++i) { new Thread(new SynchronizedTest(i, N, M)).start(); } }}
Lock锁
lock锁也是一种互斥锁,同时悲观锁。它是一种显示锁,加锁与释放锁的操作都需要手动实现,而synchronized的释放锁是自动实现的。
ReentrantLock锁内部定义了公平锁和非公平锁。对于公平锁,内部维护了一个FIFO队列用来保存进入的线程,保证先进入的线程能先执行。而对于非公平锁,如果一个线程释放了锁,其它所有线程都可以去抢这个锁,这样就会导致有些人可能会饿死,可能永远也得不到执行。但是公平锁为了实现时间上的绝对顺序,需要频繁的切换上下文,而非公平锁会减少一定的上下文切换,降低了开销。所以ReentrantLock默认采用的是非公平锁,以提高性能。
reentrantLock实现可见性是通过AQS中用volatile修饰的state来实现的,下面来分析一下原理(以非公平锁为例)。
reentrantLock先显示上锁,调用lock方法。
final void lock() { // 先尝试获取锁,也就是将state更新为1(这里用了CAS),如果获取成功,则将当前线程设置为独占模式同步的当前所有者 if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); // 如果获取失败,则进入acquire()方法 else acquire(1);}
下面进入acquire()方法:
public final void acquire(int arg) { // 调用tryAcquire尝试获取锁 // 如果获取锁失败,则用当前线程创建一个独占结点加到等待队列的尾部,并继续尝试获取锁 if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt();}
这里只进入tryAcquire看看:
protected final boolean tryAcquire(int acquires) { // 内部又调用了一个非公平的尝试获取锁方法 return nonfairTryAcquire(acquires);}
进入往下看:
final boolean nonfairTryAcquire(int acquires) { // 获取当前线程 final Thread current = Thread.currentThread(); // 重点!首先从主存中获取state(state是个volatile修饰的变量) int c = getState(); // 如果state为0,说明没有获取过锁 if (c == 0) { // 尝试获取锁 if (compareAndSetState(0, acquires)) { // 将当前线程设置为独占模式当前所有者 setExclusiveOwnerThread(current); return true; } } // 如果state不为0,说明之前获取过锁 else if (current == getExclusiveOwnerThread()) { // 将锁的数量叠加 int nextc = c + acquires; if (nextc < 0) // 溢出(超过最大锁的数量)则抛出异常 throw new Error("Maximum lock count exceeded"); // 因为当前线程已经获取了锁,在这一步不会有其它线程来干扰,所以不需要用CAS来设置state setState(nextc); return true; } return false;}
上面就是获取锁的主要代码,如果获取失败了,将会被加入到等到队列中继续尝试获取锁。(这一步不再分析)
下面再来看看释放锁的过程:
public void unlock() { // 通过内部类调用父类AbstractQueuedSynchronizer的release方法 sync.release(1);}
下面进入release方法:
public final boolean release(int arg) { // 调用tryRelease方法来尝试释放锁 if (tryRelease(arg)) { Node h = head; // 如果头节点不为空且等待状态非0 if (h != null && h.waitStatus != 0) // 如果头节点的后继节点存在,则唤醒它 unparkSuccessor(h); return true; } return false;}
reentrantLock的内部类sync重写了tryRelease方法:
protected final boolean tryRelease(int releases) { // 重点!也是首先获取state,并减去要释放的锁的数量 int c = getState() - releases; // 如果当前线程不等于当前独占模式拥有者线程 if (Thread.currentThread() != getExclusiveOwnerThread()) // 抛出一个非法监视器状态异常 throw new IllegalMonitorStateException(); boolean free = false; // 如果持有锁的数量为0 if (c == 0) { // 设置锁为可释放 free = true; // 把当前独占线程清空 setExclusiveOwnerThread(null); } // 设置state setState(c); return free;}
以上就是释放锁的关键代码
通过以上分析可知,每次在加锁和释放锁的时候,都会进入方法时先获取state,最后以设置state结束。
由于state变量是通过volatile修饰的,所以state对于所有线程是可见的,又因为volatile变量在每次强制刷新到主内存的时候,会将非volatile变量也刷新回主存。
在加锁的代码中,肯定是先调用lock(由于操作了volatile的state(先读后写),会强制刷新主存),最后调用unlock(也要操作state,会再次强制刷新主存),根据happens-before规则,volatile变量的写对于下一次的读是可见的。所以这保证了同步代码中的共享变量是可见的。
下面是一个利用reentrantLock实现的循环交替打印ABC,其中还使用了locks的条件变量condition。
import java.util.concurrent.locks.Condition;import java.util.concurrent.locks.ReentrantLock;public class ReentrantLockTest { // 定义一个显示锁 private static ReentrantLock lock = new ReentrantLock(); // 监控a的条件变量 private static Condition a = lock.newCondition(); // 监控b的条件变量 private static Condition b = lock.newCondition(); // 监控c的条件变量 private static Condition c = lock.newCondition(); // 控制要打印的值 private static int flag = 0; public static void printA() { for (int i = 0; i < 5; i++) { // 显示加锁 lock.lock(); try { try { while (flag != 0) { // 不满足监视条件则等待 a.await(); } System.out.println(Thread.currentThread().getName() + "我是A"); flag = 1; // 通知b线程去打印 b.signal(); } catch (Exception e) { e.printStackTrace(); } } finally { // 释放锁 lock.unlock(); } } } public static void printB() { for (int i = 0; i < 5; i++) { lock.lock(); try { try { while (flag != 1) { b.await(); } System.out.println(Thread.currentThread().getName() + "我是B"); flag = 2; c.signal(); } catch (Exception e) { e.printStackTrace(); } } finally { lock.unlock(); } } } public static void printC() { for (int i = 0; i < 5; i++) { lock.lock(); try { try { while (flag != 2) { c.await(); } System.out.println(Thread.currentThread().getName() + "我是C"); flag = 0; a.signal(); } catch (Exception e) { e.printStackTrace(); } } finally { lock.unlock(); } } } public static void main(String[] args) { new Thread(() -> { printA(); }).start(); new Thread(() -> { printB(); }).start(); new Thread(() -> { printC(); }).start(); }}
synchronized锁和reentrantLock锁
相同点
- 都能对资源加锁,保证线程间的同步访问。
- 都是可重入锁,即一个线程能多资源反复加锁。
- 都保证了多线程操作的原子性、有序性、可见性(这个只能保证共享变量在加锁操作内的可见性,而在加锁操作外的可见性不能得到绝对的保证,因为锁外不能保证一直从主存中获取数据,工作内存可能会不同步)
不同点
- 同步实现机制不同
- synchronized通过java对象内部的monitor锁实现(依赖操作系统的mutexLock实现,线程会被直接阻塞)
- reentrantLock通过AQS来实现(线程不会被直接阻塞,先CAS自旋获取锁,当自旋时间过长,才会将线程阻塞)
- 可见性实现机制不同
- synchronized通过java内存模型来保证可见性
- reentrantLock通过AQS的state(volatile修饰的)来保证可见性
- 监控条件不同
- synchronized通过java对象作为监控条件
- reentrantLock通过Condition(提供 await、signal 等方法)作为监控条件
- 使用方式不同
- synchronized可用来修饰实例方法(锁住实例对象)、静态方法(锁住类对象)、同步代码块(锁住指定的对象)
- reentrantLock需要显示的调用lock加锁,并需要在finally中释放锁
- 功能丰富程序不同
- synchronized只是简单的加锁
- ReentrantLock 提供定时获取锁、可中断获取锁、Condition(提供 await、signal 等方法)等特性。
- 锁类型不同
- synchronized只支持非公平锁。
- reentrantLock支持公平锁和非公平锁,但是非公平锁效率更高
在 synchronized 优化以前,它比较重量级,其性能比 ReentrantLock 要差很多,但是自从 synchronized 引入了偏向锁、轻量级锁(自旋锁)、锁消除、锁粗化等技术后,两者的性能就相差不多了。
优化后的synchronized
synchronized不管是对方法、对象还是类进行加锁,实际上锁的都是对象(实例对象或类对象),锁的信息会保存在对象头的mark work中。在对象刚被创建的时候,锁标志是偏向锁,但它还没有生效(也就是还没加锁)。
- 当线程执行到临界区(也就是同步代码块)的时候,会用CAS操作给对象加上偏向锁(加了一次锁后,如果没有其它线程来竞争锁,那么当它再次进入同步代码块时不需要进行第二次加锁)。
- 如果有两个线程来竞争锁的时候,偏向锁会失效,而升级为轻量级锁,轻量级锁是一种非阻塞同步的乐观锁。
- 自旋锁:当有其它线程来竞争锁时,这个线程不会被阻塞,而是会在原地循环等待(默认是10次,可以通过虚拟机参数更改),直到前面获取锁的线程释放锁后(大部分同步代码块执行时间都很短),这个线程就可以获得锁了。
- 自适应自旋锁:与自旋锁的区别就是循环的次数不是固定的,而是虚拟机根据实际情况来定的,如果线程刚刚获得过该锁,那么虚拟机认为它很有可能再次成功获得锁,所以会增加该线程的自旋次数,如果线程几乎没有获得过锁,那么虚拟机会减少线程自旋的次数或者直接忽略自旋。
- 如果自旋次数超了,那么轻量级锁会失效,锁会再次升级,变成重量级锁。重量级锁就是优化前的synchronized锁,它是依赖对象内部的monitor锁实现的,而monitor锁又是依赖操作系统mutexLock(互斥锁)实现的。那么现在有线程来竞争锁时,会直接被阻塞掉,因为Java中的线程都是映射到轻量级进程上的,它又是基于内核线程实现的,所以线程的阻塞和唤醒等操作都需要系统调用(需要在用户态和内核态之间来回切换,这会消耗很多CPU的,所以说重量级锁的开销很大)
乐观锁
CAS算法
即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。
CAS算法涉及到三个操作数:
- 需要读写的内存值V(在内存中的值)
- 进行比较的值A(输入的值)
- 要写入的新值B(要更新的值)
当且仅当值V等于值A时,CAS通过原子方式将值V更新为B(比较并替换是一个原子操作,unsafe底层通过操作系统来保证原子性),如果V不等于A,则失败或重试。这里没有涉及到锁操作,所以是很高效的。
但是它存在三个问题:
- 循环开销大。CAS如果长时间操作不成功(写的并发量比较大),会导致长时间自旋,从而造成CPU资源的浪费。
- 只能保证一个变量的原子操作。但是开始JDK1.5提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
- ABA问题。如果CAS先把值改为B,又改回A。在CAS看来这个值是没有变化的,但实际上是变化了的。最典型的就是ATM取钱问题:余额100,我取出50,此时ATM开了两个线程,但是一个线程暂时挂了,一个线程成功把余额更新为50,然后我朋友又给我转了50,此时余额为100,但是刚刚那个取钱的线程又活了,继续刚刚的操作,尝试将100更新为50,emmm,在CAS看来它是可以成功的,但这是不符合逻辑的(我朋友转给我的50块去哪啦???)。ABA问题的一般解决思路就是在变量前加个版本号,这样更新操作就变成了1A->2B->3A,这样CAS就会认为他们不一样了。JDK1.5开始提供AtomicStampedReference中引入了标志,这个类的compareAndSet()方法中需要当前标志和预期标志相同才能更新成功(每次更新时都会更新这个标志)。
这里结合AtomicStampedReference和CountDownLatch实现一个ABA的例子(通过版本号可以解决问题)
import java.util.concurrent.CountDownLatch;import java.util.concurrent.atomic.AtomicStampedReference;public class CasTest { // 定义一个原子类型变量 private static AtomicStampedReference<Integer> asr = new AtomicStampedReference<>(1, 0); // 定义一个线程计数器 private static CountDownLatch countDownLatch = new CountDownLatch(1); public static void main(String[] args) { new Thread(() -> { // 打印当前线程的值 System.out.println("线程 " + Thread.currentThread().getName() + " value" + asr.getReference()); // 最开始的版本 int stamp = asr.getStamp(); try { // 等待其它线程全部执行完毕(这里只需等待线程2运行结束) countDownLatch.await(); } catch (Exception e) { e.printStackTrace(); } // 将1改为2,又改为1后,再次尝试将最开始的版本的1修改为2 // 操作结果应该是失败的,因为当前版本(0)与预期版本(2)不同 // 如果将取版本号的操作放在当前,操作结果肯定是成功的(因为这里修改的1不是最开始版本的1) System.out.println(Thread.currentThread().getName() + " CAS操作结果 " + asr.compareAndSet(1, 2, stamp, stamp + 1)); }, "线程1").start(); new Thread(() -> { // 把值修改为2 asr.compareAndSet(1, 2, asr.getStamp(), asr.getStamp() + 1); System.out.println("线程 " + Thread.currentThread().getName() + " value" + asr.getReference()); // 把值修改为1 asr.compareAndSet(2, 1, asr.getStamp(), asr.getStamp() + 1); System.out.println("线程 " + Thread.currentThread().getName() + " value" + asr.getReference()); // 当前任务执行完毕,等待线程数减1 countDownLatch.countDown(); }, "线程2").start(); }}
. - 0