Java 多线程编程

2020-10-15 / Java Thread

进程与线程的区别就不过多去说了吧,进程和线程都是由操作系统进行管理与调度的,不同的是,进程与进程之间数据并不共享,各自拥有自己独立的数据存储空间。然而同一个进程下面的线程之间是可以共享进程的内存空间的。本来将从 Java 中最简单的多线程程序一点一点来讲解 Java 中多线程编程的一些方法。

0x01 实现多线程类的两种方式

继承 Thread 类

public class MyThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(i);
        }
    }
}

实现 Runnable 接口

public class MyThread implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(i);
        }
    }
}

相比继承 Thread 类,实现 RUnnable 接口的好处

  • 避免了 Java 单继承的局限性
  • 适合多个相同程序的代码去处理同一个资源的情况,把线程和程序的代码、数据有效分离,较好体现了面向对象的设计思想

创建并启动线程

  • 创建类的对象方式

    MyThread thread1 = new MyThread();
    thread1.start();
    
  • 创建 Thread 类的对象方式

    Thread thread1 = new Thread(new MyThread());
    thread1.start();
    

注意⚠️: 线程在启动应该调用其 start() 方法,而不是 run() 方法,如果调用的是 run() 方法,则程序执行是阻塞式的。只有在调用 start() 方法后,才是通过线程的方式进行执行。

0x02 设置并获取线程名称

Thread 类提供了两个方法类设置和获取线程的名称:

  • void setName(String name) 设置线程的名称
  • String getName() 获取线程名称

注: 通过 Thread.getCurrentThread() 可以获取当前正在执行的线程对象的引用。

0x03 线程调度

线程调度的两种模型:

  • 分时调度模型: 所有线程轮流获得 CPU 的使用权,平均分配每个线程占用 CPU 的时间片。
  • 抢占式调度模型:优先让优先级高的线程使用 CPU,如果线程的优先级相同,那么会随机选择一个,优先级高的线程获取的 CPU 时间片相对多一些。

Java 中使用的是抢占式调度模型。

Thread 类提供了用来设置线程优先级获取线程优先级的两个方法:

  • public final int getPriority() 获取线程优先级
  • public final void setPriority(int newPriority) 设置线程优先级

Java 中线程优先级三个常量即可说明:

public static final int MIN_PRIORITY = 1;
public static final int NORM_PRIORITY = 5;
public static final int MAX_PRIORITY = 10;

注意: 线程优先级高仅仅表示线程获取的 CPU 时间片的机率高。

0x04 线程控制

  • static void sleep(long millis) 使当前正在执行的线程暂停执行指定的毫秒数
  • void join() 等待这个线程死亡
  • void setDaemon(boolean on) 将此线程标记为守护线程,当运行的线程都是守护线程时,Java 虚拟机将退出。

0x05 线程生命周期

DA350B60-DCFE-45A5-A563-DE8C196D794A_1_105

0x06 线程同步

这里来写一个模拟售票员卖票的过程。我们会有一个票务中心(TicketCenter),以及多个售票员(TicketSeller),那么多个售票员售票就是多线程同时运行的过程,当然,每个售票员售票都是从票务中心拿的票。

首先,我们来实现一个简单的。

TicketCenter.java

import java.util.ArrayList;
import java.util.List;

/**
 * 票务中心
 */
public class TicketCenter {
    private final List<String> tickets;
    private int nextIndex;

    public TicketCenter() {
        tickets = new ArrayList<>();
        nextIndex = 0;
    }

    public void pushTicket(String ticket) {
        tickets.add(ticket);
    }

    public String getNextAvailable() {
        if (nextIndex >= tickets.size()) {
            return null;
        }
        return tickets.get(nextIndex++);
    }
}

TicketSeller.java

/**
 * 售票员
 */
public class TicketSeller implements Runnable {

    private final String sellerName;
    private TicketCenter ticketCenter;

    public void setTicketCenter(TicketCenter ticketCenter) {
        this.ticketCenter = ticketCenter;
    }

    public TicketSeller(String sellerName, TicketCenter ticketCenter) {
        this.sellerName = sellerName;
        setTicketCenter(ticketCenter);
    }

    @Override
    public void run() {
        while (true) {
            String ticket = ticketCenter.getNextAvailable();
            if (ticket == null) {
                break;
            }
            System.out.println(sellerName + " -> " + ticket);
        }
    }
}

Main.java

import java.util.UUID;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        int ticketCnt = 10000;
        TicketCenter ticketCenter = new TicketCenter();
        for (int i = 0; i < ticketCnt; i++) {
            ticketCenter.pushTicket(UUID.randomUUID().toString());
        }

        Thread seller1 = new Thread(new TicketSeller("刘备", ticketCenter));
        Thread seller2 = new Thread(new TicketSeller("关羽", ticketCenter));
        Thread seller3 = new Thread(new TicketSeller("张飞", ticketCenter));
        Thread seller4 = new Thread(new TicketSeller("赵云", ticketCenter));

        seller1.start();
        seller2.start();
        seller3.start();
        seller4.start();
    }
}

这份代码,运行结果中就有可能会出现下图所示的重复取票问题。刘备卖了一张 d9d3b45a-c131-4fa5-b9a4-7305d14677e4 的票,赵云也卖了一张 d9d3b45a-c131-4fa5-b9a4-7305d14677e4 的票。

image-20201018111115089

造成以上问题的原因就是在取票的时候,没有进行加锁操作。

那我们接下来对取票的操作进行加锁操作。则能够保证不出现重复卖票的情况。既然多个售票员取票都是通过 getNextAvailable() 函数进行取票,那我们可以对此函数进行 synchronized 修饰,则可以达到加锁的目的:

synchronized public String getNextAvailable() {
    if (nextIndex >= tickets.size()) {
        return null;
    }
    return tickets.get(nextIndex++);
}

当然,还可以通过 synchronized 同步代码块,如:

synchronized (obj) {
}

当然,这里的 obj 需要各个线程使用的是同一个对象实体。

通过上面的例子我们知道,线程同步有两种方式:

  • 同步代码块
  • 同步方法(同步方法的锁对象是 this

总结:无论是同步代码块还是同步方法,都需要明确使用的锁对象是啥,不然加锁是无效的。

注:如果是静态方法设置 synchronized 同步方法,则其锁对象是 ClassName.class 的静态文件对象。

0x07 线程安全的类

  • StringBuffer 当不需要线程安全的环境时,则建议使用 StringBuilder ,速度更快。
  • Vector 当不需要线程安全的环境时,则建议使用 ArrayList ,速度更快。
  • HashTable 当不需要线程安全的环境时候,则建议使用 HashMap,速度更快。
用途 同步类 非同步类(速度更快)
字符串构造器 StringBuffer StringBuilder
列表 Vector ArrayList
键值存储 HashTable HashMap

通过 Collections.synchronizedList() 方法,可以将一个线程不安全的列表对象,转化成一个线程安全的列表对象。

List<String> list = Collections.synchronizedList(new ArrayList<String>());

0x08 Lock 锁

Lock 实现提供比使用 synchronized 方法和语句可以获得更广泛的锁定操作

Lock 中提供了获得锁和释放锁的方法

  • void lock() 获得锁
  • void unlock() 释放锁

Lock 是接口不能直接实例化,这里采用它的实现类 ReentrantLock 来实例化

ReentrantLock 的构造方法

  • ReentrantLock 创建一个 ReentrantLock 的实例

我们将 TicketCenter.java 改成如下即可:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 票务中心
 */
public class TicketCenter {
    private final List<String> tickets;
    private int nextIndex;
    private final Lock lock = new ReentrantLock();

    public TicketCenter() {
        tickets = new ArrayList<>();
        nextIndex = 0;
    }

    public void pushTicket(String ticket) {
        tickets.add(ticket);
    }

    public String getNextAvailable() {
        if (nextIndex >= tickets.size()) {
            return null;
        }
        lock.lock();
        String ticket = tickets.get(nextIndex++);
        lock.unlock();
        return ticket;
    }
}

注意:以上所使用锁进行 lock() 操作,有一点不够严谨,那就是,如果 String ticket = tickets.get(nextIndex++); 出现异常的时候,则可能导致无法执行 lock.unlock() 操作,也就是无法释放锁,就会出现死锁的现象。那么我们可以这样写

try {
    lock.lock();
    String ticket = tickets.get(nextIndex++);
} finally {
    lock.unlock();
}

0x09 生产者消费者模式

生产者消费者模式是一个十分经典的多线程协作模式,弄懂生产者消费者问题能够让我们对多线程编程的理解更加深刻。所谓生产者消费者问题,实际上主要是包含两类线程:

  • 一类是生产者线程用于生产数据
  • 一类是消费者线程用于消费数据

为了解耦生产者和消费者的关系,通常会采用共享的数据区域,就像是一个仓库

  • 生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为。
  • 消费者只需要从共享数据区中获取数据,并不需要关心生产者的行为。

DFAD33F1-0D26-427D-A165-C12E3C3CF245_4_5005_c

为了体现生产和消费过程中的等待和唤醒,Java 就提供了几个方法供我们使用,这几个方法在 Object 类中。

Object 类的等待和唤醒方法:

方法名 说明
void wait() 导致当前线程等待,直到另一个线程调用该对象的 notify() 方法或 notifyAll() 方法
void notify() 唤醒正在等待对象监视器的单个线程
void notifyAll() 唤醒正在等待对象监视器的所有线程

生产者消费者案例

生产者消费者案例中包含的类:

  • 奶箱类(Box):定义一个成员变量,表示第 x 瓶奶,提供存储牛奶和获取牛奶的操作。
  • 生产者类(Producer):实现 Runnable 接口,重写 run() 方法,调用存储牛奶的操作。
  • 消费者类(Consumer):实现 Runnable 接口,重写 run() 方法,调用获取牛奶的操作。
  • 测试类(BoxDemo):里面有 main() 方法,main() 方法中的步骤如下:
    1. 创建奶箱对象,这是共享数据区域
    2. 创建生产者对象,把奶箱对象作为构造方法参数传递,因为在这个类中要调用存储牛奶的操作。
    3. 创建消费者对象,把奶箱对象作为构造方法参数传递,因为在这个类中要调用获取牛奶的操作。
    4. 创建两个线程对象,分别把生产者对象和消费者对象作为构造方法参数传递。
    5. 启动线程

Box.java

public class Box {
    public static final boolean BOX_EMPTY = false;
    public static final boolean BOX_FULL = true;

    // 定义一个成员变量,表示第x瓶奶
    private int milk;

    // 定义一个成员变量,表示奶箱的状态
    private boolean state = BOX_EMPTY;

    // 提供存储奶箱和获取奶箱的操作
    public synchronized void put(int milk) {
        // 如果有牛奶,等待消费
        if (state == BOX_FULL) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 如果没有牛奶,就生产牛奶
        this.milk = milk;
        System.out.printf("送奶工将第%d瓶牛奶放入奶箱。\n", milk);

        // 生产完毕后,修改奶箱状态
        state = BOX_FULL;

        // 唤醒其他等待线程
        notifyAll();
    }

    public synchronized void get() {
        // 如果没有牛奶,等待生产
        if (state == BOX_EMPTY) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 如果有牛奶,就消费牛奶
        System.out.printf("用户拿到第%d瓶牛奶。\n", this.milk);

        // 消费完毕后,修改奶箱状态
        state = BOX_EMPTY;

        // 唤醒其他等待线程
        notifyAll();
    }
}

Producer.java

public class Producer implements Runnable {

    private final Box box;

    public Producer(Box box) {
        this.box = box;
    }

    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            this.box.put(i);
        }
    }
}

Consumer.java

public class Consumer implements Runnable {

    private final Box box;

    public Consumer(Box box) {
        this.box = box;
    }

    @Override
    public void run() {
        while (true) {
            this.box.get();
        }
    }
}

BoxDemo.java

public class BoxDemo {
    public static void main(String[] args) {
        // 1. 创建奶箱对象,这是共享数据区域
        Box box = new Box();

        // 2. 创建生产者对象,把奶箱对象作为构造方法参数传递,因为在这个类中要调用存储牛奶的操作。
        Producer p = new Producer(box);

        // 3. 创建消费者对象,把奶箱对象作为构造方法参数传递,因为在这个类中要调用获取牛奶的操作。
        Consumer c = new Consumer(box);

        // 4. 创建两个线程对象,分别把生产者对象和消费者对象作为构造方法参数传递。
        Thread thread1 = new Thread(p);
        Thread thread2 = new Thread(c);

        // 5. 启动线程
        thread1.start();
        thread2.start();
    }
}