基础篇:内置锁和显式锁摸底

引言

内置锁和显式锁是 Java 的两种不同加锁机制,且是互斥的,只能二选一,不能混用。但是,笔者近几年在 CSDN 问答模块看到不少关于 synchronizedwait、notify 的提问,不是个案,而是不少类似的问题。窃以为这可能是初学者不太容易理解的知识点,笔者也是踩过很多坑后才领悟它们的用法的。

其实,只要铭记两句话,就不怕用错了,笔者的使用经验是这样的:

  1. 鱼和熊掌不可兼得
  2. waitnotify 需要裹在 synchronized 里面

笔者将内置锁和显式锁作为专栏的基础篇部分,先从大家最熟悉的 Object 类说起吧!

Object 类,你真的了解吗

Object 类是 Java 所有类的父类,查看源码,总数不到六百行,它包含了内置锁的核心 API,相信各位读者也是耳熟能详的啦:
在这里插入图片描述
红框里的这几个方法,就是我们使用内置锁的基础,JDK 的源码其实是自解释的,每个方法定义时已经包含了详细的使用说明,来跟一下它们的源码。

wait 阻塞方法

wait 的三个方法属于重载函数,最后统一调用的是 wait(long) 方法,来自 JDK 源码中的注释如下:

Causes the current thread to wait until another thread invokes the method notify or notifyAll method for this object, or some other thread interrupts the current thread, or a certain
amount of real time has elapsed.
The current thread must own this object’s monitor.

从这段文字中,我们可以知道使用该方法的场景和限制条件:

  1. 一个具有拥有 Object 锁对象的线程,才能调用该方法
  2. 该方法将导致当前线程阻塞
  3. 线程结束阻塞的条件是,另一个线程调用了 notify/notifyAll 唤醒方法,或者等待指定的时间后,自动唤醒自己

notify 唤醒方法

notifynotifyAll ,二者都是唤醒因调用锁对象的 wait 方法后被阻塞在锁对象的条件队列上的线程,notifyAll 的解释是这样的:

Wakes up all threads that are waiting on this object’s monitor

notify 稍有不同:

Wakes up a single thread that is waiting on this object’s
If any threads are waiting on this object, one of them
is chosen to be awakened. The choice is arbitrary and occurs at
the discretion of the implementation.

它每次只能唤起单个阻塞线程,如果同时有多个线程阻塞时,它会自由选择一个阻塞线程来唤醒, 而 notifyAll 则是无条件唤醒所有阻塞线程。

常见异常

Object 类的阻塞和唤醒方法,都会抛出 IllegalMonitorStateException 异常,因为当前线程必须先拥有该 Object 对象的监控器,才能调用该对象的 waitnotify 方法阻塞自己或者唤醒其他阻塞线程。

否则,贸然调用这些方法,就会遭遇监控状态非法异常了。使用内置锁的阻塞或唤醒方法,必须先拥有锁对象,这是基本前提。

内置锁和显式锁

使用内置锁和显式锁完成简单的同步代码逻辑,【这里的简单是指不使用条件队列的情况下】,没什么特别注意的地方,这是基本的使用语法:

//内置锁
synchronized(内置锁对象){
      // TODO
}

//显式锁
lock.lock();
try{
      // TODO
}finally{
     lock.unlock();
}

条件队列

条件队列,是 JVM 底层维护的一种队列,它存储的是因等待某种条件出现而被主动阻塞的线程,包含入队和出队两种操作。

条件队列不能单独存在,它必须依附于锁,它是锁的一个结构。JVM 会对条件队列上的阻塞和唤醒调用进行上下文进行检查,一旦没有锁或者锁对象和条件队列的所属对象不一致,就会抛出 IllegalMonitorStateException 异常。它是一种在获取同步锁之后因需要等待某种条件,而自动阻塞并让出锁的行为。

Java 提供了两种互斥的条件队列,分别对应两类锁:

  1. Object 类的 wait、notify 等方法是内置条件队列 API,一个 Object 对象的内置锁上只能维护一个条件队列。
  2. AbstractQueuedSynchronizer (简称 AQS)的内部类 ConditionObject 实现的显式条件队列,一个显式锁可以通过 newCondition() 维护多个条件队列,API 是 await、 signal 等方法。

内置条件队列与内置锁

内置条件队列必须与内置锁一起使用,是 has-a 的关系,锁对象有一个对应的条件队列。Java 的 Object 类有 wait、notify 两个方法,它们是内置条件队列的 API,用以唤醒或者阻塞某个线程,只有在某个内置锁的同步代码块内才能调用该对象的 waitnotify 方法【这是前面反复强调的重要知识】。

API 方法上的注释说明了它们的使用约束,例如 wait 方法上的注释说明有这段话:

This method should only be called by a thread that is the owner of this object's monitor.

内置锁和内置条件队列的基本语法是:

synchronized (obj) {
      while (<condition does not hold>)
         obj.wait(timeout, nanos);
      ... // Perform action appropriate to condition
 }

显式条件队列与显式锁

显式条件队列的实现类 ConditionObject 是显式锁 Lock 实现类的一个内部类,它的创建需要一个显式锁对象,即宿主对象。使用方法比内置条件队列稍微复杂一点,需要维护一个条件队列的实例作为类的成员变量,然后在不同的方法中根据业务逻辑使用条件队列对象的阻塞和唤醒方法。

显式锁的好处是,它可以在一把锁上创建多个条件队列,而内置锁只有一个条件队列,在多条件的场景下,显式锁更方便一些。它的使用模式为:

private Lock lock=new ReentrantLock();	
private Condition condition =lock.newCondition();

public void methodA(){
    lock.lock();//先获取锁
    try{
        if(需要等待某种条件,阻塞){
            condition.await();
        }
    }finally{
        lock.unlock();
    }
}

public void methodB(){
    lock.lock();//先获取锁
    try{
        if(已经满足某种条件,唤醒){
            condition.singnal();
        }
    }finally{
        lock.unlock();
    }
}

错误案例分析

条件队列的阻塞和唤醒操作应该由不同的线程调用,如果某一个线程被阻塞在某个条件队列上了,又没有别的线程唤醒它,它将一直处于“假死”状态。这里总结几种典型的错误用法,供读者参考。

案例一,显式锁和内置锁混用

CSDN 问答频道的一个 错误案例 ,显式锁和内置锁混用,导致运行结果混乱:

 public void stock() {
        lock.lock();
        try {
            if (money > 500) {
                try {
                    wait();
                    notify();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } else {
                // 每次调用存入100元
                money += 100;
                System.out.println("妈妈存进了100元,现在还有" + money + "元");

                // 唤醒儿子
                if (money > 200) {
                    this.notify();
                }
            }
        } finally {
            lock.unlock();
        }
    }

问题分析waitnotify 方法的调用必须被包裹在同步代码块内才能调用,否则就会报错。这里既然用了显式锁 Lock 就不能再用 wait 和 notify 了,它们是两套加锁机制。

修正思路:用 lock 创建一个 Condition ,然后在该 Conditionawait() 和 singnal()

案例二,阻塞和唤醒在同一个方法中

CSDN 问答频道的另一个 错误案例,它在同一个方法中既阻塞又唤醒,导致线程死锁,程序无法终结:

 public synchronized void run()
    {
        for (int i = 0; i < 52; i++)
        {
            if (i % 2 == 0 && i != 0)
            {
                try
                {
                    notify();;
                    this.wait();
                } catch (InterruptedException e)
                {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
            System.out.print(n + i);
        }
    }
}

问题分析:这里需要理解 Object 类的 wait() 和 notify() 、notifyAll() 的用法,它们是针对内置条件队列的阻塞和唤醒,一旦调用 wait 后,当前线程已经挂起了,即使后面的代码用了 notify,也是无效的,必须由另一个线程调用 notify 才能唤醒它。

这里的用法是错误的,定义了两个线程,各自操作自己的 waitnotify ,实际在调用 wait 后就挂起了自己,由于没有其他线程调用 notify ,导致线程一直处于挂起状态。

正确的编码逻辑是:在一个类的不同方法中,一个方法用 wait() 挂起,另一个方法中调 notify() 唤醒,并且这两个方法由不同的线程来调用。

案例三,违背锁使用常识

每个共享可变变量只能被同一把锁保护,否则它依旧是不安全的。这是一个非常重要的锁使用常识,也很好理解。如果在不同的地方,对同一个共享变量的访问用了不同的锁,就相当于有多个钥匙可以进入同一个房间,那么同一时刻该房间的访问就不是独占状态,错误的加锁等于不加锁

曾经见到一个错误的用法,貌似却用的挺对。大概是这样的,一个工作线程,访问了一个全局计数器,对这个计数器的访问使用了内置锁,示例代码为:

public class LockTest implements Runnable{
	//全局共享变量
	public static int count = 0;

	@Override
	public void run() {
		while(!Thread.currentThread().isInterrupted()) {
			//以当前实例为锁,保护 count 的操作
			synchronized (this) {
				count++;
				System.out.println(Thread.currentThread().getName()+",update count:"+count);
			}
		}
	}

	public static void main(String[] args) {
		Thread t1 = new Thread(new LockTest());
		Thread t2 = new Thread(new LockTest());
		Thread t3 = new Thread(new LockTest());
		Thread t4 = new Thread(new LockTest());
		
		t1.start();
		t2.start();
		t3.start();
		t4.start();
	}
}

预期 count 的值顺序增加,而实际结果却有重复:
在这里插入图片描述
问题分析:症结在于 synchronized (this) 用了当前实例对象作为锁,而每个线程执行时都有自己的 this 锁,导致 count 的操作过程实际是由多把锁控制,没个线程一把锁,当然 “个自为王了”。

解决办法:对 count 这个共享变量的任何操作,都使用同一把锁,比如: synchronized (LockTest.class),这样就能保证操作的互斥了:
在这里插入图片描述
“一个变量多个锁” 会出现不正确的结果,该案例给我们的启示是:遵循 “一个变量一把锁 ” 这一常识,才可能编写健壮可靠的并发程序。

以上就是本章节的内容了,下一节笔者将使用锁实现生产者消费者的通信模型。学习前车之鉴,对我们理解正确的用法也是大有裨益的!

发布了234 篇原创文章 · 获赞 494 · 访问量 37万+

猜你喜欢

转载自blog.csdn.net/wojiushiwo945you/article/details/102759506