4.线程间的同步、通信、协调和协作

        很多的时候,孤零零的一个线程工作并没有什么太多用处,更多的时候,我们是很多线程一起工作,而且是这些线程间进行通信,或者配合着完成某项工作,这就离不开线程间的通信和协调、 协作。

1 管道输入输出流

        我们已经知道,进程间有好几种通信机制,其中包括了管道,其实 Java 的线程里也有类似的管道机制,用于线程之间的数据传输,而传输的媒介为内存。

        设想这么一个应用场景:通过 Java 应用生成文件,然后需要将文件上传到 云端,比如:

  1. 页面点击导出后,后台触发导出任务,然后将 mysql 中的数据根据导出条件查询出来,生成 Excel 文件,然后将文件上传到 oss,最后发步一个下载文 件的链接。
  2. 和银行以及金融机构对接时,从本地某个数据源查询数据后,上报 xml 格 式的数据,给到指定的 ftp、或是 oss 的某个目录下也是类似的。

        我们一般的做法是,先将文件写入到本地磁盘,然后从文件磁盘读出来上传到云盘,但是通过 Java 中的管道输入输出流一步到位,则可以避免写入磁盘这 一步。

        Java 中的管道输入/输出流主要包括了如下 4 种具体实现:

        PipedOutputStream 、PipedInputStream 、PipedReader 和 PipedWriter,前两种面 向字节,而后两种面向字符。

public class Piped {
    
    public static void main(String[] args) throws Exception {
        
        PipedWriter out = new PipedWriter();
        PipedReader in = new PipedReader();
        // 将输出流和输入流进行连接,否则在使用时会抛出IOException
        out.connect(in);
        Thread printThread = new Thread(new Print(in), "PrintThread");
        printThread.start();
        int receive = 0;
        try {
            // 将键盘的输入,用输出流接受,在实际的业务中,可以将文件流导给输出流
            while ((receive = System.in.read()) != -1){
                out.write(receive);
            }
        } finally {
            out.close();
        }
    }

    static class Print implements Runnable {
        
        private PipedReader in;
        public Print(PipedReader in) {
            this.in = in;
        }

        @Override
        public void run() {
            int receive = 0;
            try {
                // 输入流从输出流接收数据,并在控制台显示, 在实际的业务中,可以将输入流直接通过网络通信写出
                while ((receive = in.read()) != -1){
                    System.out.print((char) receive);
                }
            } catch (IOException ex) {
                // TODO
            }
        }
    }
}

2 synchronized 内置锁

        线程开始运行,拥有自己的栈空间,就如同一个脚本一样,按照既定的代码 一步一步地执行,直到终止。但是,每个运行中的线程,如果仅仅是孤立地运行,那么没有一点儿价值,或者说价值很少,如果多个线程能够相互配合完成工作,包括数据之间的共享,协同处理事情。这将会带来巨大的价值。

        Java 支持多个线程同时访问一个对象或者对象的成员变量,但是多个线程同时访问同一个变量,会导致不可预料的结果。关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一 个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性,使多个线程访问同一个变量的结果正确,它又称为内置锁机制。

public class Counter {
    private int count = 0;
    public  void increment() {
        count++;
    }
    public void decrement() {
        count--;
    }
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increment();
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.decrement();
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 最终的 count 值
        System.out.println(counter.count);
    }
}

2.1 Synchronizwd 特性

2.1.1 对象锁

        关键字 synchronized 修饰非 static 方法或代码块取得的锁都是对象锁,两个 synchronized 块之间具有互斥性,换句话说,synchronized 块锁定的是整个对象,如果线程 A 访问了一个对象 A 方法的 synchronized块,那么线程 B 对同一对象 B 方法的synchronized 块的访问将被阻塞。

        synchronized同步方法:

public class Synchronized_Method {
    private int count = 0;
    public synchronized void increment() {
        count++;
    }
    public synchronized void decrement() {
        count--;
    }
    public static void main(String[] args) throws InterruptedException {
        Synchronized_Method counter = new Synchronized_Method();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increment();
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.decrement();
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 最终的 count 值
        System.out.println(counter.count);
    }
}

        A 线程持有 Object 对象的 Lock 锁,B 线程可以以异步方式调用 Object 对象中的非 synchronized 类型的方法。

        A 线程持有 Object 对象的 Lock 锁,B 线程如果在这时调用 Object 对象中的 synchronized 类型的方法则需要等待,也就是同步。

2.1.1.1 将任意对象作为对象监视器

        如果一个对象中有多个 synchronized 修饰的方法,那么同一时刻只有一个线程执行一个 synchronized 修饰的方法,其他线程调用其他方法任然会被阻塞。

        Java还支持对"任意对象"作为对象监视器来实现同步的功能。这个"任意对象"大多数是实例变量及方法的参数,使用格式为synchronized(非this对象)。

        多个线程持有"对象监视器"为同一个对象的前提下,同一时间只能有一个线程可以执行synchronized(非this对象x)代码块中的代码。

public class Synchronized_Object {
    private Object lock = new Object();
    private int count = 0;
    public void increment() {
        synchronized (lock) {
            count++;
        }
    }
    public void decrement() {
        synchronized (lock) {
            count--;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Synchronized_Object counter = new Synchronized_Object();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increment();
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.decrement();
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 最终的 counter 值
        System.out.println(counter.count);
    }
}

        synchronized(非this对象x)的三个结论:

synchronized(非this对象x)格式的写法是将 x 对象本身作为对象监视器,有三个结论得出:

  • 当多个线程同时执行 synchronized(x){} 同步代码块时呈同步效果。
  • 当其他线程执行x对象中的 synchronized 同步方法时呈同步效果。
  • 当其他线程执行x对象方法中的 synchronized(this) 代码块时也呈同步效果。

锁非 this 对象的优点:

  • 如果在一个类中有很多 synchronized 方法,这时虽然能实现同步,但会受到阻塞,从而影响效率。但如果同步代码块锁的是非 this 对象,则 synchronized(非this对象x)代码块中的程序与同步方法是异步的,不与其他锁this同步方法争抢this锁,大大提高了运行效率。
  • synchronized(非this对象x),这个对象如果是实例变量的话,指的是对象的引用,只要对象的引用不变,即使改变了对象的属性,运行结果依然是同步的。

2.1.2 类锁

        关键字 synchronized 修饰 static 方法或类(xx.class),表示对当前 .java 文件对应的 Class 类加锁,即类锁。所谓类锁,举个具体的例子。假如一个类中有一个静态同步方法 A,new 出了两个类的实例B和实例C,线程 D 持有实例 B,线程E持有实例 C,只要线程 D 调用了 A 方法,那么线程 E 调用 A 方法必须等待线程 D 执行完 A 方法,尽管两个线程持有的是不同的对象。

public class Synchronized_Class {
    
    private static int count = 0;
    public static void increment() {
        synchronized (Object.class) {
            count++;
        }
    }
    public static void decrement() {
        synchronized (Object.class) {
            count--;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                increment();
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                decrement();
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 最终的 count 值
        System.out.println(count);
    }
}

        其实类锁只是一个概念上的东西,并不是真实存在的,类锁其实锁的是每个类的对应的 class 对象,但是每个类只有一个 class 对象,所以每个类只有一个类锁。

2.1.3 synchronized 对象锁和类锁的区别

  • 无论是修饰方法还是修饰代码块都是对象锁,当一个线程访问一个带 synchronized 方法时,由于对象锁的存在,所有加synchronized的方法都不能被访问(前提是在多个线程调用的是同一个对象实例中的方法)。
  • 无论是修饰静态方法还是锁定某个class,都是类锁。一个 class 其中的静态方法和静态变量在内存中只会加载和初始化一份,所以,一旦一个静态的方法被申明为 synchronized,此类的所有的实例化对象在调用该方法时,共用同一把锁,称之为类锁。

2.1.4 synchronized锁重入

        关键字 synchronized 拥有锁重入的功能,当一个线程得到一个对象锁后,再次请求此对象锁时可以再次得到该对象的锁。锁重入的机制,也支持在父子类继承的环境中。

public class Synchronized_Reentrant {
    public static void main(String[] args) throws InterruptedException {
        DoMain doMain = new DoMain();
        MyThread myThread = new MyThread(doMain);
        myThread.start();
    }
}
class MyThread extends Thread {
    private DoMain domain;
    public MyThread(DoMain domain) {
        this.domain = domain;
    }
    @Override
    public void run() {
        domain.methodA();
    }
}

class DoMain {
    public synchronized void methodA() {
        System.out.println("我被mythread线程调用...");
        methodB();
    }
    public synchronized void methodB() {
        System.out.println("我被methodA()调用...");
        methodC();
    }
    public synchronized void methodC() {
        System.out.println("我被methodB()调用...");
    }
}

2.1.4 异常自动释放锁

        当一个线程执行的代码出现异常,并且没有捕获时,其所持有的锁会自动释放。

2.1.5 死锁

     死锁是指两个或两个以上的进程(线程)在运行过程中因争夺资源而造成的一种僵局(Deadly-Embrace) ) ,若无外力作用,这些进程(线程)都将无法向前推进。

/**
 * 一个简单的死锁类
 * 当DeadLock类的对象flag==1时(td1),先锁定o1,睡眠500毫秒
 * 而td1在睡眠的时候另一个flag==0的对象(td2)线程启动,先锁定o2,睡眠500毫秒
 * td1睡眠结束后需要锁定o2才能继续执行,而此时o2已被td2锁定;
 * td2睡眠结束后需要锁定o1才能继续执行,而此时o1已被td1锁定;
 * td1、td2相互等待,都需要得到对方锁定的资源才能继续执行,从而死锁。
 */
@Slf4j
public class DeadLockTest implements Runnable {
    public int flag = 1;
    //静态对象是类的所有对象共享的
    private static Object o1 = new Object(), o2 = new Object();

    @Override
    public void run() {
        log.info("flag:{}", flag);
        if (flag == 1) {
            synchronized (o1) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o2) {
                    log.info("1");
                }
            }
        }
        if (flag == 0) {
            synchronized (o2) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o1) {
                    log.info("0");
                }
            }
        }
    }
    public static void main(String[] args) {
        DeadLock td1 = new DeadLock();
        DeadLock td2 = new DeadLock();
        td1.flag = 1;
        td2.flag = 0;
        //td1,td2都处于可执行状态,但JVM线程调度先执行哪个线程是不确定的。
        //td2的run()可能在td1的run()之前运行
        new Thread(td1).start();
        new Thread(td2).start();
    }
}

        死锁的必要条件:

  • 互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
  • 不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放)。
  • 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  • 环路等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。即存在一个处于等待状态的进程集合{Pl, P2, …, pn},其中Pi等 待的资源被P(i+1)占有(i=0, 1, …, n-1),Pn等待的资源被P0占有。

        常用避免死锁的技术:

  • 加锁顺序(线程按照一定的顺序加锁);
  • 加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁);
  • 死锁检测;

3 等待/通知机制

        线程之间相互配合,完成某项工作,比如: 一个线程修改了一个对象的值,而另一个线程感知到了变化,然后进行相应的操作,整个过程开始于一个线程,而最终执行又是另一个线程。前者是生产者,后者就是消费者,这种模式隔离了 “做什么”(what)和“怎么做”(How),简单的办法是让消费者线程不断地 循环检查变量是否符合预期在 while 循环中设置不满足的条件,如果条件满足则 退出 while 循环,从而完成消费者的工作。却存在如下问题:

  • 难以确保及时性。
  • 难以降低开销。如果降低睡眠的时间,比如休眠 1 毫秒,这样消费者能 更加迅速地发现条件变化,但是却可能消耗更多的处理器资源,造成了无端的浪 费。

        等待/通知机制则可以很好的避免,这种机制是指一个线程 A 调用了对象 O 的 wait()方法进入等待状态,而另一个线程 B 调用了对象 O 的 notify()或者 notifyAll() 方法,线程 A 收到通知后从对象 O 的 wait()方法返回,进而执行后续操 作。上述两个线程通过对象 O 来完成交互,而对象上的 wait()和 notify/notifyAll() 的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。

  • notify():通知一个在对象上等待的线程,使其从 wait 方法返回,而返回的前提是该线程获取到了对象的锁,没有获得锁的线程重新进入 WAITING 状态。
  • notifyAll():通知所有等待在该对象上的线程。
  • wait():调用该方法的线程进入 WAITING 状态,只有等待另外线程的通知或被中断 才会返回. 需要注意,调用 wait()方法后,会释放对象的锁。
  • wait(long):超时等待一段时间,这里的参数时间是毫秒,也就是等待长达 n 毫秒,如果没有 通知就超时返回
  • wait (long,int):对于超时时间更细粒度的控制,可以达到纳秒

3.1 等待和通知的标准范式

        等待方遵循如下原则。

  1. 获取对象的锁。
  2. 如果条件不满足,那么调用对象的 wait()方法,被通知后仍要检查条件。
  3. 条件满足则执行对应的逻辑。
synchronized(obj) {
    while(条件不满足) {
        0bj.wait();    
    }
    // TODO:
}

通知方遵循如下原则。

  1. 获得对象的锁。
  2. 改变条件。
  3. 通知所有等待在对象上的线程。
synchronized(obj) {
    // 改变条件
    obj.notifyAll();
}

        在调用 wait() 、notify()系列方法之前,线程必须要获得该对象的对象级别锁,即只能在同步方法或同步块中调用 wait() 方法、notify() 系列方法,进入 wait() 方法后,当前线程释放锁,再从 wait() 返回前,线程与其他线程竞重新获得锁,执行 notify() 系列方法的线程退出调用了 notifyAll 的 synchronized 代码块的时候后,他们就会去竞争。如果其中一个线程获得了该对象锁,它就会继续往下执行,在它退出 synchronized 代码块,释放锁后,其他的已经被唤醒的线程将会继续竞争获取该锁,一直进行下去,直到所有被唤醒的线程都执行完毕。

3.2 notify 和notifyAll 应该用谁

        尽可能用 notifyall(),谨慎使用 notify() ,因为 notify() 只会唤醒一个线程,我们无法确保被唤醒的这个线程一定就是我们需要唤醒的线程

4 面试题

4.1 方法和锁

调用 yield() 、sleep() 、wait() 、notify()等方法对锁有何影响?

        yield() 、sleep()被调用后,都不会释放当前线程所持有的锁。调用 wait() 方法后,会释放当前线程持有的锁,而且当前被唤醒后,会重新去竞争锁,锁竞争到后才会执行 wait 方法后面的代码。

        调用 notify()系列方法后,对锁无影响,线程只有在 syn 同步代码执行完后才会自然而然的释放锁,所以 notify()系列方法一般都是 syn 同步代码的最后一行。

4.2 wait 和notify

        为什么 wait 和 notify 方法要在同步块中调用?

        主要是因为 Java API 强制要求这样做,如果你不这么做,你的代码会抛出 IllegalMonitorStateException 异常。 其实真实原因是:

        这个问题并不是说只在 Java 语言中会出现,而是会在所有的多线程环境下出现。

        假如我们有两个线程,一个消费者线程,一个生产者线程。生产者线程的任务可以简化成将 count 加一,而后唤醒消费者; 消费者则是将 count 减一,而后 在减到 0 的时候陷入睡眠:

生产者伪代码:

count+1;

notify();

消费者伪代码:

while(count

wait()

count--

这里面有问题。什么问题呢?

生产者是两个步骤:

1. count+1;

2. notify();

消费者也是两个步骤:

1. 检查 count 值;

2. 睡眠或者减一;

万一这些步骤混杂在一起呢?比如说,初始的时候 count 等于 0,这个时候 消费者检查 count 的值,发现 count 小于等于 0 的条件成立; 就在这个时候,发 生了上下文切换,生产者进来了,噼噼啪啪一顿操作,把两个步骤都执行完了,也就是发出了通知,准备唤醒一个线程。这个时候消费者刚决定睡觉,还没睡呢,所以这个通知就会被丢掉。紧接着,消费者就睡过去了……

0

这就是所谓的 lost wake up 问题。

那么怎么解决这个问题呢?

现在我们应该就能够看到,问题的根源在于,消费者在检查 count 到调用 wait()之间,count 就可能被改掉了。

这就是一种很常见的竞态条件。

很自然的想法是,让消费者和生产者竞争一把锁,竞争到了的,才能够修改 count 的值。

为什么你应该在循环中检查等待条件?

处于等待状态的线程可能会收到错误警报和伪唤醒,如果不在循环中检查等 待条件,程序就会在没有满足结束条件的情况下退出。因此,当一个等待线程醒 来时,不能认为它原来的等待状态仍然是有效的,在 notify()方法调用之后和等 待线程醒来之前这段时间它可能会改变。这就是在循环中使用 wait()方法效果更 好的原因。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值