Java中的锁事

Java的锁

Java根据不同的特性来对锁进行分类,大概有以下分类方式。

lock.png
这里主要讨论乐观锁和悲观锁以及在Java中对应的实现。

对于同一个数据的并发操作,悲观锁认为自己在使用数据时,一定会有其它线程来修改数据,所以在每次操作数据前都会加上一个锁,以确保没有其它线程来修改数据。Java中的synchronized锁和lock锁都是悲观锁。
而乐观锁每次都认为不会有其它线程来修改数据,所以在操作数据时不会上锁,而是在修改数据时去判断有没有其它线程修改了这个数据,如果没有被修改,则更新成功,如果已经被其它线程修改,则重新尝试或失败。Java中最常用的就是通过CAS算法来实现无锁并发编程。

twoLock.png
根据悲观锁和乐观锁的概念可以发现:

  • 悲观锁适合写多读少的场景,因为先加锁能保证写操作的正确性。
  • 乐观锁适合读多写少的场景,因为读操作一般并不需要加锁(没有修改数据),所以乐观锁的无锁特性能使读性能有很大的提升(减少了加锁等待的时间)。

悲观锁

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监视器实现
    • 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 引入了偏向锁、轻量级锁(自旋锁)、锁消除、锁粗化等技术后,两者的性能就相差不多了。

乐观锁

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();
	}
}
复制代码

猜你喜欢

转载自juejin.im/post/5df84b7e6fb9a016323d7ec3