OO_u2 pre

现在刚好在U1与U2衔接的空档期,笔者正好写完了作业没事干,所以决定看看U2的内容。说实话虽然老早就知道多线程、多进程这样概念,平常写代码需要多线程最多就是直接使用进程池(GPT把关版),这样深入地学习多线程还是第一次。
学习资料

1.多线程是什么

在古早时代,面对多个需要执行的程序/作业,计算机无法做到同时运行他们。于是程序只能一个一个地运行,像一个糖葫芦串一样一个接一个(好饿),这是程序的串行
后来科学家们发现某些程序在运行时总是频繁地等待交互响应(一会需要打印机啊、一会需要输入机啊…),占用CPU资源。俗话说占着**不拉*,聪明的科学家想出了一个好办法,趁程序拉*(交互)的时候,剥夺其对CPU的使用权,交由其他程序使用,等到它拉完了又可以继续使用CPU了。由于它们交换CPU使用权又快又频繁,看起来像多个程序在同时运行。这是程序的并发
后来随着资源越来越丰富,科学家想到给一个电脑多装几个CPU,这样程序不就能真正地一起运行了吗。所以发展出了多核CPU,两个/多个程序可以同时占用CPU资源,有不同的核(CPU)为它们提供服务。这是程序的并行

所以科学家把这一个个并发/并行的程序称为进程。在早期,进程既是系统分配资源的基本单位,也是执行的基本单元。后来,科学家们为了加大系统的并发度,又引入了线程,线程在进程之下,成为了执行的基本单元。而且由于线程的调度开销远小于进程,所以也得到了广泛的使用。多线程慢慢地不再是多进程的附庸,跨越了jieji

总之,多线程可以让我们的CPU跑的更充分。它明明可以上七休零,你要让它上五休二,不行,得狠狠压榨它

2.多线程 in Java

作为流行的编程语言,Java自然也提供了原生的线程库java.util.concurrent简称JUC。我们可以调用其提供的API快速上手多线程编程

创建线程

Java中有若干种方法创建一个线程,这里只介绍几种较为常用的方法

i. Thread

Thread类是JUC中提前定义好的线程类,我们只需要继承此类并重写run方法即可

//MyThread.java
public class MyThread extends Thread {
@Override
public void run() {
//...需要这个线程执行的操作
}
}

//Main.java
public static void main(String[] args) {
Thread t1 = new MyThread();
Thread t2 = new MyThread();
t1.start();
t2.start();
}

欸我们刚刚不是重写了run方法吗,为什么调用了start方法?

  • 虽然重写了run方法,但是如果直接调用的话,两个线程的执行实际上还是串行的,和我们之前写代码的逻辑是一样的
  • 但是调用了start方法后,线程将接受线程调度,可以并发执行,start方法内部也是调用了run方法的
    //Thread.java
    public synchronized void start() {
    ...
    group.add(this);

    boolean started = false;
    try {
    start0();
    started = true;
    } finally {
    ...
    }
    }

    private native void start0();

    /**
    * If this thread was constructed using a separate
    * <code>Runnable</code> run object, then that
    * <code>Runnable</code> object's <code>run</code> method is called;
    * otherwise, this method does nothing and returns.
    * <p>
    * Subclasses of <code>Thread</code> should override this method.
    *
    * @see #start()
    * @see #stop()
    * @see #Thread(ThreadGroup, Runnable, String)
    */
    @Override
    public void run() {
    if (target != null) {
    target.run();
    }
    }
    确实可以看到start方法内部调用了start0方法,其创建系统级进程并启动run方法,不过似乎调用run方法前还做了一些什么事,难道线程不是start就启动了吗?这里先卖个关子后面在说

ii. Runnable

看到上面的源码也说了,如果线程使用了额外的Runnable实例对象,那么就会调用实例对象的run方法。所以我们启动线程的第二个方法就是通过Runnable对象传递给线程。

//MyRunnable.java
public class MyRunnable implements Runnable {
@Override
public void run() {
//...
}
}

//Main.java
public static void main(String[] args) {
MyRunnble mr = new Runnable();
Thread t1 = new Thread(mr);
Thread t2 = new Thread(mr);
t1.start();
t2.start();
}

可以看到这第二种方法还是有些许不一样的,例如需要重写接口的方法,同时传递给不同Thread类可以是同一个Runnable实例对象,看起来很神奇,难道我不是不同的线程吗,为什么用同一个对象

  • 线程可以访问共有对象,线程共用堆空间
  • 线程共用实例对象方法不会有影响,线程不共用栈空间,每个线程有独立的栈
  • 所以实际上是不同线程复用了同一个实例对象中方法的逻辑,两个方法的执行实际上是独立的

当然我们这里使用的多线程方法都没法返回值。如果希望多线程计算等等,有兴趣的同学可以了解CallableFuture的使用,这里不涉及我们的作业,就不在此展开了。

iii. 常用的属性与方法

setName(String) -> void

  • 可以给你的线程取一个洋气的名字,默认是Thread-x(x是main线程开始后开启的线程序号0、1…)

getName() -> String

  • 同上,获取线程的名字

Thread(String str)

  • 当然也可以在创建线程是直接给它一个名字

{static} currentThread() -> Thread

  • 执行到此语句时的线程对象,例如希望在Runnable对象中获取线程实例

{static} sleep(int) -> void

  • 让执行此条语句的线程陷入休眠(TMIED_WATING)状态

setDaemon(Thread) -> void

  • 为当前线程设置一个守护线程,当前线程结束,守护线程将逐步暂停

setPriority(int) -> void

  • 设置线程执行优先级,优先级越高,被执行的概率越大
  • 三个值MIN_PRIORITY: 1NORM_PRIORITY: 5MAX_PRIORITY: 10

线程闹鬼事件

CVBB引入了一个马戏团,准备在下周五表演,CVBBer对此都十分感兴趣。为了照顾同学们宝贵的课余时间,主办方拜托小熊写一个多窗口的售票平台,方便同学们抢票。共计3个窗口,100张门票。事不宜迟,我们赶紧开始吧

我们先浅浅地实现一个售票机

//VendingMachine.java
public class VendingMachine {
private static int ticket = 0;

public void getTicket() {
while (true) {
if (canGetTicket()) {
break;
}
}
}

private boolean canGetTicket() {
if (ticket < 100) {
ticket++;
System.out.println("ticket@" + ticket + " sold!");
return true;
}
return false;
}
}

//Main.java
public static void main(String[] args) {
VendingMachine vm = new VendingMachine();
vm.getTicket();
}

>>> ticket@1 sold!
>>> ticket@2 sold!
...
>>> ticket@100 sold!

测试一下,嗯,果然运行地很好。现在我们把它改为多线程吧,无非就是实现Runnable罢了

public class VendingMachine implements Runnable {
private static int ticket = 0;

@Override
public void run() {
while (true) {
if (canGetTicket()) {
break;
}
}
}
...
}

//Main.java
public static void main(String[] args) {
VendingMachine vm = new VendingMachine();
Thread t1 = new Thread(vm);
Thread t2 = new Thread(vm);
Thread t3 = new Thread(vm);
t1.start();
t2.start();
t3.start();
}

>>> ticket@3 sold!
>>> ticket@3 sold!
>>> ticket@3 sold!
...
>>> ticket@101 sold!
>>> ticket@102 sold!
>>> ticket@102 sold!

aaa,完蛋了。不仅票卖重复了,甚至还卖出了假票(),这究竟是怎么回事呢
我们观察canGetTicket方法内部的逻辑,当三个线程同时进入该方法后,按如下顺序继续执行

t1 -> ticket++
t2 -> ticket++
t3 -> ticket++
t1 -> sout(...)
t2 -> sout(...)
t3 -> sout(...)

啊,原来线程t1执行完票号加一操作没有立刻打印出来,CPU执行权被截胡了,最后导致了悲剧的发生。那我们到底应该如何解决这个问题呢

数据安全

刚刚讲到了线程引发的赛博闹鬼事件。那么我们应该如何避免这种现象的发生呢
观察我们刚刚发现的问题

  • 多个相同的票号是因为 ticket自增与打印值两个操作并非一气呵成的,也就是非原子操作
  • 超出范围的票号是因为 判断ticket大小与ticket自增两个操作并非一气呵成的,也是因为非原子操作

我们当然很难去把这种操作实现为一个底层的汇编指令。虽然这样能从根本上解决什么原子、非原子问题。但是理论上我们需要的原子操作是无限的,但是指令集是有限的。
于是我们把原子操作放在某个对象上,对于这个对象的获取与释放是原子操作,这是互斥锁的一个简陋的描述

synchronized关键词

关于synchronized的底层实现是极为复杂的,因为Java8之后,synchronized的实现进行了大量的优化操作,其中涉及到锁的升级等等。但是我们可以简单地认为它是通过原子操作请求和释放锁的

1. synchronized同步代码块解决互斥

同一时间,只能有一个线程进入同步代码块执行操作,其余线程阻塞在外部
对于一个共有的变量上锁,通常可以使用.class文件

private static Object lock = new Object();

public void run() {
while (true) {
//synchronized (VendingMachine.class)
synchronized (lock) {
//...
}
}
}
2. synchronized同步方法解决互斥

和上面同步代码块实际是一致的

  • 对于非静态方法,默认锁住this实例对象,即同一时间一个实例对象中所有的synchronized修饰的非静态方法中只能有一个方法被一个线程使用
  • 对于静态方法,默认锁住class类对象,即同一时间一个中所有synchronized修饰的静态方法只能有一个方法被一个线程使用
  • class类对象和this对象显然非同一对象,所以synchronized修饰的静态和非静态方法之间并没有限制
public void run() {
while (true) {
if (canGetTicket(...))
}
}

private synchronized boolean canGetTicket() {
//...
}
wait、notify解决同步问题

在上述情况下,我们只关注对于共用资源的访问同一时刻只能由一个线程执行。但是在某些情况下,不仅这种资源的访问是互斥的,顺序也是需要依照一定的规律

吃青提
小明的妈妈给他买了一盒阳光青提。妈妈开始洗青提,每洗好一个青提就会放在盘子中,直到盘子装满了为止。小明同时开始吃青提,直到盘子为空为止。已知共有50个青提,盘子最多盛放3个青提。
我们可以比较容易地看出可以为小明和妈妈都设置一个线程,一个负责放一个负责吃。而且对于盘子这个资源的访问应该是互斥的。这样是否就足够了呢???如果刚开始小明进程开始执行,发现盘子中没有青提,小明应该怎么办?

  • 首先盘子是共用的资源(临界资源),所以对盘子的访问是互斥的
  • 其次,线程结束的标志是青提洗完了???(真的是这个吗?)
  • 小明吃完了青提,想要再次吃的时候必须要阻塞(如何阻塞),要去唤醒妈妈继续洗(如何唤醒)
  • 盘子中装满了青提时,妈妈要是还想放也需要阻塞,要去通知小明吃青提

首先是盘子,这个容器最好写了,不需要任何方法,定义一些属性即可

//Plate.java
public class Plate {
public static int maxTimes = 50; //最多使用次数

public static int capacity = 3; //最大容量

public static int now = 0; //当前容量

public static Object lock = new Object(); //互斥锁对象
}

其次就是妈妈,其实就是生产者,负责将东西放入盘子中

  • 生产者会一直尝试将东西放入盘子中,直到盘子放满则阻塞次数耗尽则死亡
  • wait方法会将当前线程“挂”在锁下,并释放锁,线程进入阻塞(BLOCKED)状态
  • notifyAll方法将唤醒所有被挂在该对象下的线程,但最终只能有一个线程获得执行权,其他线程继续进入阻塞状态,等待下一次唤醒抢夺CPU控制权
  • attentionwait方法需要在while循环中使用,因为有时线程会被“虚假唤醒”,莫名地就会执行后面的语句,极不安全
    @Override
    public void run() {
    while (true) {
    try {
    if (produce()) {
    break;
    }
    } catch (InterruptedException e) {
    throw new RuntimeException(e);
    }
    }
    }

    private boolean produce() throws InterruptedException {
    synchronized (Plate.lock) {
    if (Plate.maxTimes == 0) {
    return true;
    }
    while (Plate.now >= Plate.capacity) {
    Plate.lock.wait();
    }
    Plate.now++;
    Plate.maxTimes--;
    System.out.println(Thread.currentThread().getName() + " produce grape, with " + Plate.maxTimes + " left");
    Plate.lock.notifyAll();
    return false;
    }
    }
    最后是小明,第一次写还是有很多问题的(:sob:
  • 消费者进程会一直尝试从盘子中拿出食物,直到盘为空则阻塞???次数耗尽则死亡???
  • 结束的条件并不只是次数耗尽,次数耗尽指生产者已经全部生产完食物,但是消费者有没有消耗完呢?
    • 假设生产者线程结束时,所有消费者都在consume方法开始,那么接下来每个消费者线程发现Plate.maxTimes == 0即退出,那么生产者最后生产的物品并没有被消耗
  • 唤醒条件即当前盘是否为空,只有盘为空消费者才能继续执行下去吗?
    • 假设现在生产者线程已经结束,有若干消费者进程在等待,其中一个消费者消耗了生产者最后生产的若干物品并正常退出了,剩下的消费者怎么办???**即使它们被唤醒,但是检查发现Plate.now == 0**又会继续等待,程序将无法终止
  • 所以真正的条件应该如下,而非注释中的一般情况
    //run...
    private boolean consume() throws InterruptedException {
    // Thread.sleep(20);
    synchronized (Plate.lock) {
    // if (Plate.maxTimes == 0) ???
    if (Plate.maxTimes == 0 && Plate.now == 0) {
    return true;
    }
    // while (Plate.now <= 0) ???
    while (Plate.now <= 0 && Plate.maxTimes != 0) {
    Plate.lock.wait();
    }
    if (Plate.now > 0) {
    Plate.now--;
    index++;
    System.out.println(Thread.currentThread().getName() + " consume grape");
    }
    Plate.lock.notifyAll();
    return false;
    }
    }

ReentrantLock

Lock对象是JUC中的显式锁,需要用户手动上锁/关锁,但是是一个接口,我们需要使用它的一个实现类ReetrantLock
例如将上面代码改写

//Plate.java
public static Lock lock = new ReetrantLock();
public static Condition condition = lock.newCondition();

//Consumer.java
//上锁
Plate.lock.lock();
if (Plate.maxTimes == 0 && Plate.now == 0) {
return true;
}
while (Plate.now <= 0 && Plate.maxTimes != 0) {
//阻塞进程
Plate.condition.await();
}
if (Plate.now > 0) {
Plate.now--;
index++;
System.out.println(Thread.currentThread().getName() + " consume grape");
}
//唤醒阻塞进程
Plate.condition.signalAll();
return false;
//解锁
Plate.lock.unlock();

再次运行程序,发现了神奇的一幕,程序无法结束了。这是为什么呢?

  • 我们查看刚刚改过的代码,发现return true;时没有把锁打开,导致了锁一直处于关闭状态
  • 我们应该使用finally块,使得无论如何都要将锁打开
    Plate.lock.lock();
    try {
    ....
    return true;
    } finally {
    Plate.lock.unlock();
    }

ReadWriteLock

读写锁手动实现

读写锁,这个涉及到另一个经典的多线程问题————“读者与写者问题”

  • 对于一个文件的操作可以分为读、写两种线程
  • 读线程可以有多个同时一起读文件
  • 写线程必须与读线程和其他写线程互斥

这个问题最有意思的一点在于,我们应该如何让多个读线程不互斥

  • 这个问题和刚刚的盘子容量有些类似,只是这次盘子的容量是无限大的,所以我们可以这样考虑:永远只给第一个读线程上锁,永远只给最后一个读线程解锁
    //read
    private Lock lock = new ReetrantLock(); //读写锁
    private int count = 0; // 记录所有的读进程

    if (count == 0) {
    lock.lock();
    }
    count++;
    read();// 真正的读操作
    count--;
    if (count == 0) {
    lock.unlock();
    }
    但是这样还有问题,因为count == 0count++不是原子操作,可能导致多个读者线程进入if区域尝试获取锁,导致读者们相互阻塞,并不是我们希望看到的。同理下方的count也需要保护
    //read
    public static int Lock lock = new ReetrantLock(); //读写锁
    private Lock mutex = new ReetrantLock();
    private int count = 0; // 记录所有的读进程

    mutex.lock();
    if (count == 0) {
    lock.lock();
    }
    count++;
    mutex.unlock();
    read();// 真正的读操作
    mutex.lock();
    count--;
    if (count == 0) {
    lock.unlock();
    }
    mutex.unlock();
    写进程也是同理了
    lock.lock();
    write(); // 真正的写操作
    lock.unlock();
    最后观察发现,我们实现的锁是非公平锁,甚至有些太不公平了。如果一直有读线程到来,写线程最终可能会饿死。所以我们可以多设置一个锁,保持公平。但是于此同时,程序的性能会受到一些影响。
    public static int Lock priority = new ReetrantLock();

    priority.lock();
    //写线程原有代码
    priority.unlock();

    priority.lock();
    //读线程前半段获取读写锁内容
    priority.unlock();
    read(); //真正的读线程
    //...
  • 这样保证了如果在读者们读文件时,写者先到来,那么接下来会轮到写者执行,priority锁保证了读写操作的公平性。但是实际读写锁常用于读操作显著高于写操作频率时。
ReetrantReadWriteLock

当然JUC已经实现了我们刚刚说的读写锁。而且性能、稳定性各方面都是碾压我们手写的(毕竟它底层实现不仅有众多原子操作优化、锁优化,还能享受到JVM级的一些优化

ReedWriteLock lock = new ReentrantReadWriteLock();
Lock rLock = lock.readLock();
Lock wLock = lock.writeLock();

rLock.lock();
read();
rLock.unlock();

wLock.lock();
write();
wLock.unlock();

升级与降级
如果一个线程写完了之后发现自己还想读该怎么办,写锁降级为读锁

  • ReetrantReadWriteLock支持重入,一个获取到写锁的线程再去获取读锁并不会阻塞自己,而是可以正常地获取
    wLock.lock();
    write();
    //还想读
    rLock.lock();
    wLock.unlock();
    read();
    rLock.unlock();
    如果一个线程读完之后发现自己还想写该怎么办,读锁升级为写锁???
  • 错误的,获取到读锁的同时不允许同时再去获取写锁,这样可能导致同时在读取的线程发生数据不一致错误,必须先释放读锁然后再尝试获取写锁
    rLock.lock();
    read();
    rLock.unlock();
    wLock.lock();
    write();
    wLock.unlock();

Semaphore

信号量是一个伟大的发明,JUC的信号量实际上就是记录型自变量。信号量声明了一个资源的多少,允许多个线程去获取这种资源,但是超过拥有资源的限制后,线程将被“挂”在信号量下,等待资源被其他线程释放。

  • 听着好熟悉,感觉和小明吃青提的问题很像。
  • 是的,其实解决生产者消费者问题的代码实现,是我手动模拟了一个信号量。还是那句话,既然Java已经实现好了我们用就是了

思考一下信号量实现同步问题,因为获取信号量时如果没有默认会进入wait状态,而释放信号量时也默认会去通知被阻塞的进程。我们的代码实际可以写的更加简洁li

  • 细细分析一下生产者与消费者的同步关系,下面的“生产”、“消耗”对应着信号量的意义,是对于资源而言的
  • 消费者消耗完食物 -> 通知生产者生产(消费者生产“空资源”, 生产者消耗“空资源”)
  • 生产者生产食物过剩 -> 通知消费者消费(生产者生产“食物资源”,消费者消耗“食物资源”)
  • 所以我们不妨设置两个意义相反的信号量用于指代资源。那理所应当的,起始时空资源大小应该等于盘子的大小,食物资源的大小应该为0
    //Plate
    public static int maxTimes = 100; //总的生产次数
    public static Semaphore full = new Semaphore(0); //食物资源,这里为了反义词
    public static ArrayList<Integer> foods = new ArrayList<>(); //盘子
    public static Semaphore empty = new Semaphore(3); //空资源
    //Consumer
    //消耗食物资源
    Plate.full.acquire();
    synchronized (Plate.class) {
    if (Plate.maxTimes == 0 && Plate.foods.isEmpty()) {
    //既然没用就归还资源
    Plate.full.release();
    return true;
    }
    int index = Plate.foods.remove(0);
    consumed.add(index);
    System.out.println(Thread.currentThread().getName() + " consume grape@" + index);
    }
    //生产空资源
    Plate.empty.release();
    return false;
    //Producer
    //获取空资源
    Plate.empty.acquire();
    synchronized (Plate.class) {
    if (Plate.maxTimes == 0) {
    //既然没用就归还资源
    Plate.empty.release();
    return true;
    }
    Plate.maxTimes--;
    int index = 100 - Plate.maxTimes;
    Plate.foods.add(index);
    produced.add(index);
    System.out.println(Thread.currentThread().getName() + " produce grape@" + index);
    }
    //生产食物资源
    Plate.full.release();
    return false;
    接下来我们运行发现,消费者消费完之后程序停不下来了。看看结束的情况,感觉也没问题啊,没用的资源还回去怎么会出问题呢
  • 这里还是假设生产者生成了最后三个食物资源后死亡。接下来有三个消费者线程分别消耗一个食物资源。此时刚好所有食物也消耗完了,是时候结束进程了。这是某一个消费者线程希望通过if (Plate.maxTimes == 0 && foods.isEmpty())返回,但是它必须先获得一个食物资源Plate.full.acquire,此时生产者线程已经全部死亡,而我们分析食物资源也已经耗尽,程序陷入死锁状态
  • 解决方案
    • 先判断一遍是否可以提前返回再获取资源(缺点:麻烦,在多个地方返回写同样的语句)
      synchronized (Plate.class) {
      if (Plate.maxTimes == 0 && foods.isEmpty()) {
      //...
      return true;
      }
      }
      Plate.full.acquire();
      synchronized (Plate.class) {
      if (...) {
      return true;
      }
      }
    • 让生产者线程死亡前多留一个食物资源用于退出,使用finally即可
      try {
      Plate.empty.acquire();
      synchronized (Plate.class) {
      if (...) {
      ....
      return true;
      }
      }
      } finally {
      Plate.full.release();
      }
      我们显然可以证明,多留出多少个食物资源都不会影响消费者消耗的数量,因为正常情况下生产者还会将生产的食物放入foods。所以前面的食物资源将最后的food消耗了之后,剩余的食物资源则被用于退出,而且退出前还会归还这个多余的食物资源,就算只有一个食物资源剩余,所有的消费者进程都是可以正常退出的不会受到任何影响!

最后还有一个小小的问题,为什么不能将获取资源的操作放在互斥锁内部

synchronized (Plate.class) {
Plate.full.acquire();
....
}
  1. acquire()release()方法使用原子操作,线程安全不需要上锁
  2. 放在内部会造成死锁现象,我们需要遵循额反正是一条避免死锁的原则,一个对象我们保证其先获得所有执行使用的资源再给他执行的机会,而不是一边给他资源,一边让他等其他资源。
    • 假设现在消费者先拿到互斥锁,然后请求食物资源。食物资源没有了,于是消费者主动放弃了CPU执行权给生产者,希望生产者生产食物资源。但是因为生产者拿不到互斥锁,导致无法生产食物资源,二者陷入了死锁困境。

总结

Java多线程远不止这些:sob:,还有各种千奇百怪的锁(自旋锁等等),JUC也提供了若干优化的策略,例如无锁原子操作CompareAndSwapTestAndSet等等,还有锁使用策略例如轻量锁、自旋锁、重量锁等等。多线程永无止境!