Java零基础入门笔记21-Java多线程

1、进程与线程概述

  • 进程(英语:process),是计算机中已运行程序的实体
    • 进程曾经是分时系统的基本运作单位。
    • 在面向进程设计的系统(如早期的UNIX,Linux 2.4及更早的版本)中,进程是程序的基本执行实体;
    • 面向线程设计的系统(如当代多数操作系统、Linux 2.6及更新的版本)中,进程本身不是基本运行单位,而是线程的容器程序本身只是指令、数据及其组织形式的描述进程才是程序(那些指令和数据)的真正运行实例
  • 如下所示为Windows资源管理器,选择【进程】选项卡,则能看到运行在电脑上的所有进程。
    这里写图片描述
  • 线程(英语:thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
  • 科普:我们都知道,程序的运行是靠CPU来处理的,若只有一个CPU的情况下,如何保证多个程序能够同时运行呢?如下图所示,我们把CPU的执行时间分成很多的小块,每一小块的时间都是固定的,我们把这个小块称为时间片,时间片的时间是很短的(例如只有1ms),若此时有音乐播放器、代码编辑器、qq等软件同时运行,那么这些软件会随机获取CPU的执行时间,简单说就是比如播放器运行1ms然后把使用权交给代码编辑器,代码编辑器运行1ms然后把使用权交给qq,即这些软件会随机地获取cpu的执行时间,对CPU来说,这些软件是在轮流运行的,但是由于运行时间间隔很短,作为使用者是很难感觉到这种变化的,因此对使用者来说,这些软件是在同时运行的。以上的运行方式称为时间片轮转

2、线程的创建

  • 创建多线程共有三种方式:
    • 继承Thread类,重写run()方法,run()方法代表线程要执行的任务。
    • 实现Runnable接口,重写run()方法,run()方法代表线程要执行的任务。
    • 实现Callable接口,重写call()方法,call()作为线程的执行体,具有返回值,并且可以对异常进行声明和抛出;使用start()方法来启动线程。

2.1、通过Thread类创建线程

  • Thread是一个线程类,位于java.lang包下,常用的构造方法如下:
构造方法 说明
Thread() 创建一个线程对象
Thread(String name) 创建一个具有指定名称的线程对象
Thread(Runnable target) 创建一个基于Runnable接口实现类的线程对象
Thread(Runnable target,String name) 创建一个基于Runnable接口实现类,并且具有指定名称的线程对象
  • Thread类的常用方法
方法 说明
public void run() 线程相关的代码写在该方法中,一般需要重写
public void start() 启动线程的方法
public static void sleep(long m) 线程休眠m毫秒的方法
public void join() 抢占资源,优先执行调用调用join()方法的线程

  • 1.新建一个名为ThreadProj的Java项目,新建一个com.cxs.thread包,在包下新建一个MyThread类并继承自Thread类,访问修饰符选择package
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println(getName() + "该线程正在执行!");//getName()获取系统自动分配的线程名
    }
}
  • 2.在同包下新建一个测试类ThreadTest
public class ThreadTest {
    public static void main(String[] args) {
        System.out.println("我是主线程");

        MyThread mThread = new MyThread();
        mThread.start();// 启动线程

        System.out.println("我是主线程");
    }
}
  • 3.运行代码,如下图所示,运行着主线程和Thread-0两个线程。
    这里写图片描述
  • 4.每个线程只允许被启动一次,连续两次启动线程则程序会发生异常。
    这里写图片描述
  • 5.修改上面的MyThread代码,如下所示。
class MyThread extends Thread {

    public MyThread(String name) {
        super(name);
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(getName() + "正在运行" + i);
        }
    }
}
  • 6.修改上面的ThreadTest代码,如下所示。
public class ThreadTest {
    public static void main(String[] args) {
        MyThread mThread1 = new MyThread("线程1");
        MyThread mThread2 = new MyThread("线程2");
        mThread1.start();
        mThread2.start();
    }
}
  • 7.运行两次代码,得到不同的结果,这说明,线程运行时是随机获取CPU的使用权的。
    这里写图片描述

2.2、通过实现Runnable接口创建线程

  • Runnable是Java中用以实现线程的接口
  • Runnable接口中只有一个方法run();
  • 任何实现线程功能的类都必须实现该接口(Thread类也实现了该接口,详见API文档)
  • 上面已经讲了创建多线程的方式,为什么还要通过实现Runnable接口来创建线程?一种方式不够吗?主要有以下两方面的原因。
    • 由于Java不支持多继承,若你创建的类已经继承了一个类,再去继承Thread类,这样是不可能的(只能是单继承)。但是接口的话可以实现多个接口。
    • 不打算重写Thread类的其他方法,并且在实际使用中,通过实现Runnable接口来创建线程应用的更广泛一些。

  • 1.新建一个com.cxs.runnable包,在包下新建一个RunnableDemo类,并实现Runnable接口
class RunnableDemo implements Runnable {
    @Override
    public void run() {
        int i = 1;
        while (i <= 10) {
            System.out.println(Thread.currentThread().getName() + "正在运行!" + (i++));
        }
    }
}
  • 2.在包下新建一个RunnableTest测试类。
public class RunnableTest {
    public static void main(String[] args) {
        RunnableDemo r1 = new RunnableDemo();
        Thread t1 = new Thread(r1);
        t1.start();

        RunnableDemo r2 = new RunnableDemo();
        Thread t2 = new Thread(r2);
        t2.start();
    }
}
  • 3.运行代码结果如下。
    这里写图片描述
  • 4.修改一下两个类的代码。
class RunnableDemo implements Runnable {
    int i = 1;//←——将i变为成员变量

    @Override
    public void run() {
        while (i <= 10) {
            System.out.println(Thread.currentThread().getName() + "正在运行!" + (i++));
        }
    }
}

public class RunnableTest {
    public static void main(String[] args) {
        RunnableDemo r1 = new RunnableDemo();
        Thread t1 = new Thread(r1);
        t1.start();

        Thread t2 = new Thread(r1);// 两个线程运行同一个实现类对象
        t2.start();
    }
}
  • 5.运行代码,结果如下。在实现类中的run()方法可以被多个线程执行,也就是是多个线程在处理同一个任务的情况。
    这里写图片描述

2.3、通过实现Callable接口创建线程

  • 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。
  • 创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
  • 使用FutureTask对象作为Thread对象的target,创建并启动新线程。
  • 调用FutureTask对象的get()方法,来获得子线程执行结束后的返回值。

  • 1.创建一个com.cxs.callable包,在包下新建一个CallableDemo类,并实现Callable接口。
class CallableDemo implements Callable<String> {
    @Override
    public String call() throws Exception {
        String str = "多线程的第三种创建方式";
        return str;
    }
}
  • 2.新建一个测试类Test(运行结果略)。
public class Test {
    public static void main(String[] args) {
        Callable<String> callable = new CallableDemo();
        FutureTask<String> futureTask = new FutureTask<>(callable);
        Thread thread = new Thread(futureTask);
        thread.start();

        // 先启动线程后才可以获取call()方法的返回值
        try {
            System.out.println(futureTask.get());
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }
}

3、线程的生命周期

3.1、生命周期概述

  • 线程的状态:
    • 新建(New)
    • 可运行(Runnable),也称为就绪
    • 正在运行(Running)
    • 阻塞(Blocked)
    • 终止(Dead)
  • 如下图所示为线程的生命周期:
    这里写图片描述
  • 创建线程对象使线程进入新建状态
  • 通过调用start()方法来启动线程,使线程进入可运行状态【此时线程若获取cpu使用权后才会进入正在运行状态,而cpu使用权是有时间限制的,一旦到了期限即时间片用完或者调用yield()方法,线程便又会进入可运行状态,去等待获取下一次的cpu使用权】
  • 通过调用join()、wait()、sleep()方法或者进行I/O请求,使线程从正在运行的状态进入阻塞状态【阻塞状态是不能直接变为正在运行状态的,因为只有获取cpu的使用权才能进入正在运行状态,因此要先进入可运行状态,之后才能进入正在运行状态】
  • 阻塞状态只有在以下情况才会进入可运行状态
    • 等待调用join()的线程执行完毕;
    • 调用notify()或notifyAll()方法(对应的wait()方法,即调用wait()方法的线程只有调用notify()或notifyAll()方法才能进入可运行状态)
    • 之前调用sleep()方法阻塞线程,当休眠超时以后,线程便会重新进入可运行状态;
    • 一旦I/O请求完成,线程便会从阻塞状态进入可运行状态。
  • 线程执行完毕或异常终止,会进入终止状态。以上四个状态都可以通过调用stop()方法使线程进入终止状态。【stop()现在已经不建议使用了,在jdk中会显示版本过期】

更多有关线程终止的内容请参考下面两篇博客:

3.2、sleep方法的使用

  • sleep()方法是Thread类中的方法,形式为:public static void sleep(long millis),显然该方法是一个静态方法,在调用时一般会使用Thread.sleep()形式去掉用。
  • 该方法作用:在指定的毫秒数内让正在执行的线程休眠(暂停执行,进入阻塞状态)。
  • 参数为休眠时间,单位是毫秒。
  • 这里不再进行代码演示,比如休眠1秒,则在run()方法中添加Thread.sleep(1000)并捕获InterruptedException异常即可,大家可自行在前面代码的基础上进行尝试。

3.3、join方法的使用

  • join()方法也是Thread类中的方法,形式为:public final void join(),执行该方法也需捕获InterruptedException异常。
  • join()方法的作用:等待调用该方法的线程结束后才能执行,即调用join()方法的线程优先执行,待其执行完毕,其余线程才会执行。(演示略)
  • public final void join(long millis),该方法为等待该线程终止的最长时间为millis毫秒。

3.4、线程的优先级

  • Java为线程类提供了10个优先级。
  • 优先级可以用整数1-10表示,超过范围会抛出异常。
  • 主线程默认优先级为5,数字越大,优先级越高。
  • 除了数字外还可以采用优先级常量来表示线程的优先级:
    • MAX_PRIORITY:线程的最高优先级10;
    • MIN_PRIORITY:线程的最低优先级1;
    • NORM_PRIORITY:线程的默认优先级5;
  • 优先级相关的方法:
    • public int getPriority():获取线程的优先级,例如:int mainPriority=Thread.currentThread().getPriority();可以获取当前线程的优先级。
    • public void setPriority(int newPriority):设置线程的优先级。可以在线程启动前为线程设置优先级。(可在前面编写的创建线程的代码基础上进行该方法的尝试,这里不再演示)

注意:即使为线程设置了优先级,但由于程序的运行与操作系统的环境和cpu的工作方式等因素有关,并不能保证优先级高的线程优先执行,还会存在一定的随机性。

4、线程同步

  • 多线程运行存在以下问题:
    • 各个线程是通过竞争cpu时间而获得运行机会的。
    • 各个线程什么时候得到cpu时间,占用多久,是不可预测的。
    • 一个正在运行着的线程在什么地方被暂停是不确定的。

下面我们来模拟一个银行存取款的案例
  • 1.新建一个com.cxs.bank的包,在包下新建一个银行类Bank
public class Bank {
    private String account;// 账号
    private int balance;// 账户余额

    public Bank(String account, int balance) {
        this.account = account;
        this.balance = balance;
    }

    public String getAccount() {
        return account;
    }

    public void setAccount(String account) {
        this.account = account;
    }

    public int getBalance() {
        return balance;
    }

    public void setBalance(int balance) {
        this.balance = balance;
    }

    @Override
    public String toString() {
        return "Bank [账号:" + account + ", 余额:" + balance + "]";
    }

    // 存款
    public void saveAccount() {
        int balance = getBalance();// 获取当前账号余额
        balance += 100;// 存100元并修改余额
        setBalance(balance);// 修改当前账户余额
        System.out.println("存款后的账户余额为(存款100):" + balance);
    }

    // 取款
    public void drawAccount() {
        int balance = getBalance();// 获取当前账号余额
        balance -= 200;// 取200元并修改余额
        setBalance(balance);// 修改当前账户余额
        System.out.println("取款后的账户余额为(取款200):" + balance);
    }
}
  • 2.在bank包下新建一个存款类SaveAccount并实现Runnable接口,并将存款操作放于线程中(即run()方法中)执行。
public class SaveAccount implements Runnable {
    Bank bank;

    public SaveAccount(Bank bank) {
        this.bank = bank;
    }

    @Override
    public void run() {
        bank.saveAccount();
    }
}
  • 3.在bank包下新建一个取款类DrawAccount并实现Runnable接口,并将取款操作放于线程中(即run()方法中)执行。
public class DrawAccount implements Runnable {
    Bank bank;

    public DrawAccount(Bank bank) {
        this.bank = bank;
    }

    @Override
    public void run() {
        bank.drawAccount();
    }
}
  • 4.在bank包下新建一个测试类Test。执行过程为:创建一个银行账户,初始余额为1000启动存款线程执行存款操作启动取款线程执行取款操作打印输出账户最终余额
public class Test {
    public static void main(String[] args) {
        Bank bank = new Bank("s001", 1000);

        SaveAccount saveAccount = new SaveAccount(bank);
        DrawAccount drawAccount = new DrawAccount(bank);

        Thread saveThread = new Thread(saveAccount);
        Thread drawThread = new Thread(drawAccount);

        saveThread.start();
        drawThread.start();

        System.out.println("经过存取款操作后,账户的余额为(初始余额为1000):" + bank);
    }
}
  • 5.程序真会按照我们预先设定好的思路运行吗?运行代码结果如下所示。多次执行代码,发现得到的结果不一样。很显然,由于各个线程是通过竞争cpu时间而获得运行机会的(上面总结的多线程存在的问题有说到),存在不确定性。
    这里写图片描述
为了能让存款和取款的线程优先执行,因此让这两个线程执行join()方法。
  • 6.修改测试类Test代码如下:
public class Test {
    public static void main(String[] args) {
        Bank bank = new Bank("s001", 1000);

        SaveAccount saveAccount = new SaveAccount(bank);
        DrawAccount drawAccount = new DrawAccount(bank);

        Thread saveThread = new Thread(saveAccount);
        Thread drawThread = new Thread(drawAccount);

        saveThread.start();
        drawThread.start();
        // 为了让打印余额的语句最后执行,则让两个线程执行join()方法而优先执行
        try {
            saveThread.join();
            drawThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("经过存取款操作后,账户的余额为(初始余额为1000):" + bank);
    }
}
  • 7.再次运行程序,结果如下,多次运行后,发现存款和取款的操作执行顺序仍然不确定。
    这里写图片描述
为了模拟更真实的场景,比如在银行存取款的人有好多,那么就有好多存取款的线程,这么多线程在运行,就不能保证存取款的线程都能正常完成,也就是该线程在什么地方被暂停运行是不确定的,这里我们用线程休眠的方式进行模拟。
  • 8.修改Bank类中的存取款方法,在两个方法的随机位置休眠1秒。
// 存款
public void saveAccount() {
    int balance = getBalance();// 获取当前账号余额

    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    balance += 100;// 存100元并修改余额
    setBalance(balance);// 修改当前账户余额
    System.out.println("存款后的账户余额为(存款100):" + balance);
}

// 取款
public void drawAccount() {
    int balance = getBalance();// 获取当前账号余额
    balance -= 200;// 取200元并修改余额

    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    setBalance(balance);// 修改当前账户余额
    System.out.println("取款后的账户余额为(取款200):" + balance);
}
  • 9.运行程序,多次运行程序得到下面两种情形。
    这里写图片描述

    • 为了保证在存款或取款的时候,不允许其他线程对账户余额进行操作,需要对Bank对象进行锁定
    • 这里我们使用关键字synchronized实现。它能确保共享对象在同一时刻只能被一个线程访问。
    • synchronized关键字可以用在三个地方:
      • 成员方法:public synchronized void saveAccount(){}
      • 静态方法:public static synchronized void saveAccount(){}
      • 语句块:synchronized (obj){……}
  • 10.给Bank类中存取款方法加上关键字synchronized。

// 存款
public synchronized void saveAccount() {// ←——第一种方式
    int balance = getBalance();// 获取当前账号余额

    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    balance += 100;// 存100元并修改余额
    setBalance(balance);// 修改当前账户余额
    System.out.println("存款后的账户余额为(存款100):" + balance);
}

// 取款
public void drawAccount() {
    synchronized (this) {// ←——第三种方式
        int balance = getBalance();// 获取当前账号余额
        balance -= 200;// 取200元并修改余额

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        setBalance(balance);// 修改当前账户余额
        System.out.println("取款后的账户余额为(取款200):" + balance);
    }
}
  • 11.再次运行代码,多次运行,结果一致。
    这里写图片描述

5、线程间通信

下面我们模拟这样一个场景,我们建一个生产线名为Queue,有生产者Producer和消费者Consumer两个角色,Producer负责生产(这里是将一个数字加一,代表生产了一件产品),Consumer负责消费,将生产的产品展示出来即代表消费,然后新建两条线程分别进行生产和消费,那么我们会遇到哪些问题呢?又该如何解决?
  • 1.新建一个com.cxs.queue的包,在包下新建一个Queue类。定义一个数字,并编写gettersetter方法,并加上同步关键字synchronized
public class Queue {
    private int n;

    public synchronized int getN() {
        System.out.println("消费:" + n);
        return n;
    }

    public synchronized void setN(int n) {
        System.out.println("生产:" + n);
        this.n = n;
    }
}
  • 2.新建一个Producer类并实现Runnable接口,为了更真实地模拟,在数字加1后让线程休眠1秒。
public class Producer implements Runnable {
    Queue queue;

    public Producer(Queue queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        int i = 0;
        while (true) {
            queue.setN(i++);

            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
  • 3.新建一个Consumer类并实现Runnable接口,为了更真实地模拟,在输出数字后让线程休眠1秒。
public class Consumer implements Runnable {
    Queue queue;

    public Consumer(Queue queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        while (true) {
            queue.getN();

            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
  • 4.新建一个测试类Test
public class Test {
    public static void main(String[] args) {
        Queue queue = new Queue();

        new Thread(new Producer(queue)).start();
        new Thread(new Consumer(queue)).start();
    }
}
  • 5.运行应用,结果如下,生产和消费并没有轮流执行。
    这里写图片描述
我们这里可以将Queue看做一个容器,在其中定义一个布尔值变量flag,当flag为false时代表容器中没有数据,这时就需要调用set方法去生产数据,数据生产完毕后,flag的值就变为true,这时就可以调用get方法去获取数据了。同理,当flag为false时,我们是不能消费的,此时需要等待(调用wait()方法使线程进入阻塞状态),当flag为true时,我们就不需要再生产了,此时也需要等待。即建立一种线程间的通信,使得程序按照我们预期进行运转。
  • 6.修改Queue类的代码。
public class Queue {
    private int n;
    boolean flag = false;// true代表有数据,false代表没数据

    public synchronized int getN() {
        if (!flag) {// 若没有数据,则没法消费,则需要等待
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("消费:" + n);
        flag = false;// 消费完毕,容器就没有数据了
        return n;
    }

    public synchronized void setN(int n) {
        if (flag) {// 若有数据则需要等待被消费
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("生产:" + n);
        this.n = n;
        flag = true;// 生产完毕,则容器中就有数据了
    }
}
  • 7.运行程序,发现程序运行一会儿便停止了生产和消费,但是程序没有停止运行。
    这里写图片描述
这是因为发生了死锁,即两个线程都进入了阻塞状态,这时就需要通过notify()或notifyAll()来,这里我们使用notifyAll()来唤醒所有的线程。
  • 8.在getter和setter方法中都添加notifyAll()方法,再次运行程序,此时程序就会按照预期运行(结果图略)。
public class Queue {
    private int n;
    boolean flag = false;// true代表有数据,false代表没数据

    public synchronized int getN() {
        if (!flag) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("消费:" + n);
        flag = false;
        notifyAll();// ←——
        return n;
    }

    public synchronized void setN(int n) {
        if (flag) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("生产:" + n);
        this.n = n;
        flag = true;
        notifyAll();// ←——

    }
}

猜你喜欢

转载自blog.csdn.net/chaixingsi/article/details/82453639