您现在的位置是:网站首页> 内容页

线程间的协作机制

  • www.bbo776.com
  • 2019-04-09
  • 312人已阅读
简介上篇文章我们介绍了synchronized这个关键字,通过它可以基本实现线程间在临界区对临界资源正确的访问与修改。但是,它依赖一个Java对象内置锁,某个时刻只能由一个线程占有

上篇文章我们介绍了 synchronized 这个关键字,通过它可以基本实现线程间在临界区对临界资源正确的访问与修改。但是,它依赖一个 Java 对象内置锁,某个时刻只能由一个线程占有该锁,其他试图占有的线程都得阻塞在对象的阻塞队列上。

但实际上还有一种情况也是存在的,如果某个线程获得了锁但在执行过程中由于某些条件的缺失,比如数据库查询的资源还未到来,磁盘读取指令的数据未返回等,这种情况下,让线程依然占有 CPU 等待是一种资源上的浪费。

所以,每个对象上也存在一个等待队列,这个队列上阻塞了所有获得锁并处于运行期间缺失某些条件的线程,所以整个对象的锁与队列状况是这样的。

Entry Set 中阻塞了所有试图获得当前对象锁而失败的线程,Wait Set 中阻塞了所有在获得锁运行期间由于缺失某些条件而交出 CPU 的线程集合。

而当某个现场称等待的条件满足了,就会被移除等待队列进入阻塞队列重新竞争锁资源。

wait/notify 方法

Object 类中有几个方法我们虽然不常使用,但是确实线程协作的核心方法,我们通过这几个方法控制线程间协作。

public final native void wait(long timeout)public final void wait()public final native void notify();public final native void notify();

wait 类方法用于阻塞当前线程,将当前线程挂载进 Wait Set 队列,notify 类方法用于释放一个或多个处于等待队列中的线程。

所以,这两个方法主要是操作对象的等待队列,也即是将那些获得锁但是运行期间缺乏继续执行的条件的线程阻塞和释放的操作。

但是有一个前提大家需要注意,wait 和 notify 操作的是对象内置锁的等待队列,也就是说,必须在获得对象内置锁的前提下才能阻塞和释放等待队列上的线程。简单来说,这两个方法的只能在 synchronized 修饰的代码块内部进行调用

下面我们看一段代码:

public class Test { private static Object lock = new Object(); public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(){ @Override public void run(){ synchronized (lock){ try { lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } }; Thread thread2 = new Thread(){ @Override public void run(){ synchronized (lock){ System.out.println("hello"); } } }; thread1.start(); thread2.start(); Thread.sleep(2000); System.out.println(thread1.getState()); System.out.println(thread2.getState()); }}

运行结果:

可以看到,程序是没有正常结束的,也就是说,有线程还未正常退出。线程一优先启动于线程二,所以它将先获得 lock 锁,接着调用 wait 方法将自己阻塞在 lock 对象的等待队列上,并释放锁交出 CPU。

线程二启动时可能由于线程一依然占有锁而阻塞,但当线程一释放锁以后,线程二将获得锁并执行打印语句,随后同步方法结束并释放锁。

此时,线程一依然阻塞在 lock 对象的等待队列上,所以整个程序没有正常退出。

演示这么一段程序的意义是什么呢?就是想告诉大家,虽然阻塞队列和等待队列上的线程都不能得到 CPU 正常执行指令,但是它们却属于两种不同的状态,阻塞队列上的线程在得知锁已经释放后将公平竞争锁资源,而等待队列上的线程则必须有其他线程通过调用 notify 方法通知并移出等待队列进入阻塞队列,重新竞争锁资源。

相关方法的实现

1、sleep 方法

sleep 方法用于阻塞当前线程指定时长,线程状态随即变成 TIMED_WAITING,但区别于 wait 方法。两者都是让出 CPU,但是 sleep 方法不会释放当前持有的锁。

也就是说,sleep 方法不是用于线程间同步协作的方法,它只是让线程暂时交出 CPU,暂停运行一段时间,时间到了将由系统调度分配 CPU 继续执行。

2、join 方法

join 方法用于实现两个线程之间相互等待的一个操作,看段代码:

public void testJoin() throws InterruptedException { Thread thread = new Thread(){ @Override public void run(){ for (int i=0; i<1000; i++) System.out.println(i); } }; thread.start(); thread.join(); System.out.println("main thread finished.....");}

抛开 join 方法不谈,main 线程中的打印方法一定是先执行的,而实际上这段程序会在线程 thread 执行完成之后才执行主线程的打印方法。

实现机理区别于 sleep 方法,我们一起看看:

方法的核心就是调用 wait(delay) 阻塞当前线程,当线程被唤醒计算从进入方法到当前时间共经过了多久。

接着比较 millis 和 这个 now,如果 millis 小于 now 说明,说明等待时间已经到了,可以退出方法返回了。否则则说明线程提前被唤醒,需要继续等待。

需要注意的是,既然是调用的 wait 方法,那么等待的线程必然是需要释放持有的当前对象内置锁的,这区别于 sleep 方法。

一个典型的线程同步问题

下面我们写一个很有意思的代码,实现操作系统中的生产者消费者模型,借助我们的 wait 和 notify 方法。

生产者不停生产产品到仓库中直到仓库满,消费者不停的从仓库中取出产品直到仓库为空。如果生产者发现仓库已经满了,就不能继续生产产品,而消费者如果发现仓库为空,就不能从仓库中取出产品。

public class Repository { private List<Integer> list = new ArrayList<>(); private int limit = 10; //设置仓库容量上限 public synchronized void addGoods(int count) throws InterruptedException { while(list.size() == limit){ //达到仓库上限,不能继续生产 wait(); } list.add(count); System.out.println("生产者生产产品:" + count); //通知所有的消费者 notifyAll(); } public synchronized void removeGoods() throws InterruptedException { while(list.size() <= 0){ //仓库中没有产品 wait(); } int res = list.get(0); list.remove(0); System.out.println("消费者消费产品:" + res); //通知所有的生产者 notifyAll(); }}

写一个仓库类,该类提供两个方法供外部调用,一个是往仓库放产品,如果仓库满了则阻塞到仓库对象的等待队列上,一个是从仓库中取出产品,如果仓库为空则阻塞在仓库的等待队列上。

public class Producer extends Thread{ Repository repository = null; public Producer(Repository p){ this.repository = p; } @Override public void run(){ int count = 1; while(true){ try { Thread.sleep((long) (Math.random() * 500)); repository.addGoods(count++); } catch (InterruptedException e) { e.printStackTrace(); } } }}

定义一个生产者类,生产者随机的向仓库添加产品。如果没有能成功的添加,会被阻塞在循环里。

public class Customer extends Thread{ Repository repository = null; public Customer(Repository p){ this.repository = p; } @Override public void run(){ while(true){ try { Thread.sleep((long) (Math.random() * 500)); repository.removeGoods(); } catch (InterruptedException e) { e.printStackTrace(); } } }}

定义一个消费者类,消费者类随机的从仓库中取一个产品。如果没有成功的取出一个产品,同样会被阻塞在循环里。

public void testProducerAndCustomer() { Repository repository = new Repository(); Thread producer = new Producer(repository); Thread consumer = new Customer(repository); producer.start(); consumer.start(); producer.join(); consumer.join(); System.out.println("main thread finished..");}

主线程启动这两个线程,程序运行的情况大致是这样的:

生产者生产产品:1消费者消费产品:1生产者生产产品:2消费者消费产品:2生产者生产产品:3消费者消费产品:3。。。。。。。。。。消费者消费产品:17生产者生产产品:21消费者消费产品:18生产者生产产品:22消费者消费产品:19生产者生产产品:23消费者消费产品:20生产者生产产品:24生产者生产产品:25生产者生产产品:26消费者消费产品:21生产者生产产品:27生产者生产产品:28消费者消费产品:22消费者消费产品:23生产者生产产品:29生产者生产产品:30。。。。。。。。。。。。

仔细观察,你会发现,消费者者永远不会消费一个不存在的产品,消费的一定是生产者生产的产品。刚开始可能是生产者生产一个产品,消费者消费一个产品,而一旦消费者线程执行的速度超过了生产者,必然会由于仓库容量为空而被阻塞。

生产者线程的执行速度可以超过消费者线程,而消费者线程的执行速度如果一直超过生产者就会导致仓库容量为空而致使自己被阻塞。

总结一下,synchronized 修饰的代码块是直接使用的对象内置锁的阻塞队列,线程获取不到锁自然被阻塞在该队列上,而 wait/notify 则是我们手动的控制等待队列的入队和出队操作。但本质上都是利用的对象内置锁的两个队列。

这两篇文章介绍的是利用 Java 提供给我们的对象中的内置锁来完成基本的线程间同步操作,这部分知识是后续介绍的各种同步工具,集合类框架等实现的底层原理。


文章中的所有代码、图片、文件都云存储在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

欢迎关注微信公众号:OneJavaCoder,所有文章都将同步在公众号上。

文章评论

Top