Java根据不同的特性来对锁进行分类,大概有以下分类方式。
这里主要讨论乐观锁和悲观锁以及在Java中对应的实现。
对于同一个数据的并发操作,悲观锁认为自己在使用数据时,一定会有其它线程来修改数据,所以在每次操作数据前都会加上一个锁,以确保没有其它线程来修改数据。Java中的synchronized锁和lock锁都是悲观锁。
而乐观锁每次都认为不会有其它线程来修改数据,所以在操作数据时不会上锁,而是在修改数据时去判断有没有其它线程修改了这个数据,如果没有被修改,则更新成功,如果已经被其它线程修改,则重新尝试或失败。Java中最常用的就是通过CAS算法来实现无锁并发编程。
根据悲观锁和乐观锁的概念可以发现:
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锁也是一种互斥锁,同时悲观锁。它是一种显示锁,加锁与释放锁的操作都需要手动实现,而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 引入了偏向锁、轻量级锁(自旋锁)、锁消除、锁粗化等技术后,两者的性能就相差不多了。
即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。
CAS算法涉及到三个操作数:
当且仅当值V等于值A时,CAS通过原子方式将值V更新为B(比较并替换是一个原子操作,unsafe底层通过操作系统来保证原子性),如果V不等于A,则失败或重试。这里没有涉及到锁操作,所以是很高效的。
但是它存在三个问题:
这里结合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(); } } 复制代码