JavaSE - 多线程

JavaSE - 多线程

本节学习目标:

  1. 了解线程的概念以及线程和进程的区别;
  2. 了解并掌握实现线程的两种方式;
  3. 了解并熟悉线程的生命周期;
  4. 了解并掌握操作线程的方法;
  5. 了解线程的优先级;
  6. 了解并掌握线程安全与线程同步机制。

1. 线程概述

1.1 线程简介

我们可以同时完成很多事情,比如一边吃饭一边看手机。电脑可以同时进行打印文件,下载文件,听歌等很多活动。

这些活动可以同时进行,在Java中同时进行某些操作的思想被称为并发(Concurrent),而并发完成的每一个操作称为线程(Thread)。

1.2 多线程概述

不是所有的编程语言都支持线程,在以往的程序中,多以一个任务完成后再进行下一个任务的模式进行开发,这样下一个任务的开始之前必须等待上一个任务的结束。Java语言提供了并发机制,可以在程序中同时执行多个线程,每一个线程完成一个功能,并与其他线程并发执行,这种机制被称为多线程(Multithreading)。

1.3 进程简介

Windows操作系统是多任务操作系统,它以进程(Process)为单位。一个进程是一个包含有自身地址的程序,每个独立执行的程序都称为进程,也就是正在执行的程序。

1.4 线程与进程的关系与区别

一个线程是进程中的执行流程,一个进程中可以同时包括多个线程,每个线程可以得到一小块程序的执行时间,这样一个进程就可以具有多个并发执行的线程。

线程是进程的基本执行单元,一个进程的所有任务都要在线程中执行。进程想要执行任务必须拥有线程,一个进程至少拥有一个线程。程序启动会默认开启一个线程,比如main线程。

线程与进程的区别:

  1. 地址空间:同一进程的线程共享本进程的地址空间,而进程与进程之间的地址空间是独立的;
  2. 资源拥有:同一进程的线程共享本进程的资源(如内存,I/O,CPU资源等),而进程与进程之间的资源是独立的;
  3. 进程之间不会相互影响,但是进程的某个线程出现问题,则可能会导致整个进程崩溃;
  4. 每个独立的进程都有一个程序执行的入口,顺序执行队列与程序执行的出口,但是线程不能独立执行,必须依存在进程中,由进程提供多个线程进行控制;
  5. 线程是处理器调度的基本单位,但是进程不是。

线程与进程的区别是什么?- 知乎

2. 实现线程的方式

Java提供了多种实现线程的方式,这里主要学习两种方式:

  1. 继承java.lang.Thread类;
  2. 实现java.lang.Runnable接口。

2.1 继承 Thread 类

Thread类是java.lang包中的一个类,这个类实例化的对象代表线程,要启动一个新线程需要建立Thread实例。

Thread类中常用的构造方法如下:

  • Thread():创建一个新的线程对象;
  • Thread(String name):创建一个新的线程对象并指定线程名称;
  • Thread(Runnable target):以传入的Runnable对象创建一个新的线程对象并调用此Runnable对象的run()方法;
  • Thread(Runnable target, String name):以传入的Runnable对象创建一个新的线程对象并调用此Runnable对象的run()方法,可以指定线程名称。

Thread类的核心方法run()start()

  • start():调用此方法来启动线程
  • run()线程启动后会自动调用此方法,我们可以重写此方法来为线程添加任务

编写代码进行测试:

public class TestThread extends Thread {
    private final int[] arr = {456, 14, 69, -57, 47, 61, 67, 97, 10, 67};
    
    @Override
    public void run() {
        for (int i = 0; i < this.arr.length; i++) {
            if (i == 0) {
                System.out.print(this.arr[i] + " ");
            } else {
                System.out.print(this.arr[i - 1] + this.arr[i] + " ");
            }
        }
    }
    
    public static void main(String[] args) {
        new TestThread().start();
    }
}

运行结果:

456 470 83 12 -10 108 128 164 107 77 

2.2 实现 Runnable 接口

如果一个类已经继承了其他类,还需要实现多线程怎么办?我们知道Java只支持单继承,因此无法再继承Thread类。

为了解决这种问题,Java提供了Runnable接口,通过实现此接口也可以实现多线程。

Runnable接口是java.lang包的一个接口,这个接口只有一个方法run(),同时被@FunctionalInterface注解标注,因此它是一个函数式接口,在JDK1.8中可以使用lambda表达式实现。

编写代码进行测试:

public class TestRunnable {
    public static final char[] arr = {'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd', '!'};
    public static void main(String[] args) {
        Runnable r = new Runnable() {
            @Override
            public void run() {
                for (char c : arr) {
                    System.out.print(c);
                }
            }
        };
        //  使用lambda表达式编写上述代码:
        //  Runnable r = () -> {
        //      for (char c : arr) {
        //          System.out.print(c);
        //      }
        //  };
        new Thread(r).start();
    }
}

运行结果:

Hello World!

翻阅Java源代码就会发现Thread类其实实现了Runnable接口,而Thread类的run()方法就是重写了Runnable接口的run()方法。

3. 线程的生命周期

线程具有生命周期,一共有5个状态:

  1. 新建状态(New);
  2. 就绪状态(Runnable);
  3. 运行状态(Running);
  4. 阻塞状态(Blocked);
  5. 死亡销毁)状态(Terminated);

线程的生命周期图解( Java中什么方法导致线程阻塞 - Chin_style/CSDN ):

线程的生命周期图解

3.1 出生与就绪状态

使用new关键字创建一个线程对象,该线程就处于新建状态,直到调用了该线程对象的start()方法。在这个状态的线程对象和普通的Java对象相同,
Java虚拟机为其初始化相关变量分配内存,此时这个线程对象并没有线程的特征。

当调用了线程对象的start()方法后,该线程就处于就绪状态,Java虚拟机会为其创建方法调用栈程序计数器,处于就绪状态的线程并没有开始运行,
只是表示这个线程可以运行了,至于什么时候运行取决于Java虚拟机的调度,具有一定的随机性

3.2 运行与阻塞状态

当处于就绪状态的线程被Java虚拟机调度并获得了CPU资源,它就会自动执行run()方法,进入运行状态
一个CPU同时只能运行一个线程,如果有多个线程需要运行,CPU会在这些线程中不停跳转,执行一个线程一会就要跳转到另一个线程去执行。
由于CPU运行速度非常快,所以看起来就像这些线程在同时运行。

线程的调度取决于底层平台所采用的策略,所有的现代桌面和服务器操作系统采用的是抢占式调度策略:系统会给每一个处于就绪状态的的线程一个时间段来处理任务,获得CPU资源的的线程在这个时间段内处于运行状态,执行相关任务。当该时间段结束后,系统就会剥夺该线程占用的CPU资源,给其他线程使用。
某些小型设备,比如手机,则可能采用协作式调度策略:获得CPU资源的线程直到完成任务后进入死亡状态,或者出于某些原因被阻塞,进入阻塞状态后,才会让出CPU资源给其他线程使用。

线程会进入阻塞状态的常见情况:

  • 线程的sleep()方法或wait()方法被调用;
  • 线程调用了一个阻塞式I/O方法(如查询数据库等),在该方法返回之前,此线程处于阻塞状态;
  • 线程执行某个同步(synchronized)方法,但并未获得锁,等待获得锁期间处于阻塞状态。

进入阻塞状态的线程会在合适的时候进入就绪状态,而不是进入运行状态。当线程阻塞解除后,必须等待被调度并获得CPU后,线程才会进入运行状态
让上述情况的线程重新进入就绪状态的情况:

  • 线程的sleep()方法已经超时;
  • 被调用了wait()方法的线程被调用了notify()方法或notifyAll()方法;
  • 阻塞式I/O方法已经返回;
  • 线程获得了同步锁。

3.3 死亡状态

线程会在如下情况进入死亡状态

  • run()方法返回,任务全部完成;
  • 线程发生了异常且未被捕获;
  • 线程的stop()方法被调用(容易导致死锁,不建议使用);
  • 调用了System.exit()方法,或由于其他原因使Java虚拟机终止运行,此时所有的线程生命周期均强制结束。

4. 操作线程的方法

Thread类提供了很多操作线程的方法,可以让线程从一种状态进入另一种状态。

4.1 线程休眠

Thread类提供了sleep()方法:

  • static sleep(long millis):让线程休眠指定的毫秒数;
  • static sleep(long millis, int nanos):让线程休眠指定的毫秒数和纳秒数;

线程休眠会让线程进入阻塞状态,休眠结束(即超时)后线程将进入就绪状态

编写代码进行测试:

import java.util.Date;
public class TestSleep extends Thread {
    @Override
    public void run() {
        try {
            System.out.println(new Date());
            System.out.println(new Date());
            Thread.sleep(2000); // 休眠2秒
            System.out.println(new Date());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) {
        new TestSleep().start();
    }
}

运行结果:

Tue Nov 09 16:57:38 CST 2021
Tue Nov 09 16:57:38 CST 2021
Tue Nov 09 16:57:40 CST 2021

4.2 线程插队

假如存在一个线程A,现在需要插入线程B,并要求执行完线程B的任务之后才能继续执行线程A的任务,此时可以使用Thread类提供的join()方法实现此操作。
比如在某视频网站看视频,突然插播一则广告,必须将广告看完才能继续看视频。

  • join(long millis):向当前线程中插入另一个线程,并经过指定毫秒后使插入的线程强制进入死亡状态
  • join(long millis, int nanos):向当前线程中插入另一个线程,并经过指定毫秒和纳秒后使插入的线程强制进入死亡状态
  • join():向当前线程中插入另一个线程,当前线程必须等待插入的线程执行完毕

线程插队会使线程进入阻塞状态,当插入的线程执行完毕或者超时后,线程将进入就绪状态

编写代码进行测试:

public class TestJoin {
    public static void main(String[] args) {
        Thread A = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    for (int i = 0; i < 30; i++) {
                        System.out.print(1);
                        Thread.sleep(100);
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread B = new Thread(() -> {
            try {
                // A.join(); // 调用线程A的join()方法
                for (int i = 0; i < 30; i++) {
                    System.out.print(2);
                    Thread.sleep(100);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        A.start();
        B.start();
    }
}

运行结果:

122121121221211221211221211212121221121221121212121221122112

可以看到不使用join()方法时,两个线程是交替执行的。调用join()方法后的运行结果:

111111111111111111111111111111222222222222222222222222222222

调用了线程A的join()方法后,B线程需要等待A线程执行完才能继续执行。

4.3 线程中断

在过去使用stop()方法停止当前线程,但现在stop()方法已经过时(被@Deprecated注解标注,表示不推荐使用),原因是容易出现死锁
现在提倡在run()方法中使用死循环的形式,然后使用一个布尔型变量控制循环的停止:

public class TestThread extends Thread {
    private boolean breakFlag = false;
    @Override
    public void run() {
        while (true) {
            // 循环体
            if (setBreak) {
                break;
            }
        }
    }
    public void setBreakFlag(boolean breakFlag) {
        this.breakFlag = breakFlag;
    }
    public boolean getBreakFlag() {
        return this.breakFlag;
    }
}

Thread类提供的interrupt()方法可以告知当前线程被打断,线程本身可以对这个操作进行处理,如使用isInterrupted()方法判断自己有没有被打断,如果被打断则执行某些操作:

public class TestInterrupt extends Thread {
    @Override
    public void run() {
        int i = 0;
        while (true) {
            System.out.print("输出" + i + " ");
            if (this.isInterrupted()) {
                break;
            }
            i++;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        TestInterrupt thread = new TestInterrupt();
        thread.start();
        Thread.sleep(1);
        thread.interrupt();
    }
}

运行结果:

输出0 输出1 输出2 输出3 输出4 输出5 输出6 输出7 输出8 输出9 输出10 输出11 输出12 输出13 输出14 输出15 输出16 

如果线程因为sleep()方法或者wait()方法被调用而进入阻塞状态,可以调用Thread类提供的interrupt()方法。
此时程序会抛出InterruptedException异常,我们可以在处理此异常时实现线程中断。

编写代码进行测试:

public class TestInterrupt extends Thread {
    @Override
    public void run() {
        while (true) {
            System.out.println("输出");
            try {
                sleep(100);
            } catch (Exception e) {
                e.printStackTrace();
                break;
            }
        }
    }
    public static void main(String[] args) {
        TestInterrupt thread = new TestInterrupt();
        thread.start();
        thread.interrupt();
    }
}

运行结果:

输出
java.lang.InterruptedException: sleep interrupted
	at java.lang.Thread.sleep(Native Method)
	at TestInterrupt.run(TestInterrupt.java:7)

4.4 线程礼让

Thread类还提供了yield()静态方法,此方法是本地方法(使用native关键字修饰)。该方法的作用是向当前正处于运行状态的线程一个提示,告知线程可以将占用的资源放弃,并礼让其他线程。但这仅仅是一个提示没有任何机制能保证调用此方法的线程一定会礼让

5. 线程的优先级

每个线程都具有各自的优先级(Priority),线程的优先级可以表示才程序中该线程的重要性。如果有很多线程处于就绪状态,系统会根据优先级来决定优先将CPU资源给予哪个线程,让其进入运行状态。优先级只会影响被调度器调度的几率并不能使低优先级的线程无法运行

Thread类已经定义了三个优先级常量

优先级常量类型说明
MIN_PRIORITYint最低优先级,设置优先级时不能低于此常量1
NORM_PRIORITYint线程的默认优先级5
MAX_PRIORITYint最高优先级,设置优先级时不能高于此常量10

每个新产生的线程会继承创建该线程的线程的优先级。

线程的优先级可以使用Thread类提供的setPriority()方法调整,如果调整的优先级数字不在1~10之内,将会抛出IllegalArgumentException异常。

6. 线程安全与线程同步

多线程会带来资源占用冲突的问题,比如一个厕所同时只能一个人用,多个人同时用显然是不可能的,多个人使用时必须加以限制来防止冲突,比如排队等;
Java提供了线程同步机制来防止多线程访问同一个资源时发生的冲突。

6.1 线程安全

以售票系统为例,简单说明为什么什么是线程安全:

public class Ticket extends Thread {
    private int tickets = 10;
    @Override
    public void run() {
        while (true) {
            if (tickets <= 0) {
                break;
            }
            tickets--;
            try {
                sleep(100);
                System.out.println("当前剩余门票数量:" + tickets);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        Thread t1 = new Thread(ticket);
        Thread t2 = new Thread(ticket);
        Thread t3 = new Thread(ticket);
        t1.start();
        t2.start();
        t3.start();
    }
}

运行结果:

当前剩余门票数量:9
当前剩余门票数量:8
当前剩余门票数量:10
当前剩余门票数量:7
当前剩余门票数量:5
当前剩余门票数量:6
当前剩余门票数量:3
当前剩余门票数量:2
当前剩余门票数量:4
当前剩余门票数量:1
当前剩余门票数量:-1
当前剩余门票数量:0

从运行结果可以看出售票系统出现了很大的问题,首先是显示剩余票数明显错误,而且剩余票数竟然出现了负值。这是由于三个线程同时访问同一个资源(即例子中的票数),没有加以限制导致三个线程随意访问,一个线程进入休眠状态还未执行至自减操作时,另一个线程再次读取了资源,导致资源访问不一致,获取的数据为脏数据。这个例子就是非线程安全的。

6.2 线程同步机制

针对上述问题,Java提供了线程同步机制,将资源访问加以限制。同时只允许一个线程访问资源,同时给访问此资源的线程一把,拥有锁的线程可以访问资源,访问结束后或规定的访问时间超时后,会把锁交出去,给予其他未持有锁的线程,这样其他线程获得了锁就可以访问资源。

Java提供了synchronized关键字来实现线程同步机制,主要有两个使用方法:

1. 同步块

可以将访问资源的有关代码块使用同步块包括,从而实现线程安全。

同步块的语法格式:

synchronized (Object) {
    // 语句块
}

其中的Object同步监视器,就是上文提到的,可以设置为任意对象

将章节6.1中的售票系统的例子使用同步块修改后:

public class Ticket extends Thread {
    private int tickets = 10;
    @Override
    public void run() {
        while (true) {
            synchronized (this) {
                if (tickets <= 0) {
                    break;
                }
                tickets--;
                try {
                    sleep(100);
                    System.out.println("当前剩余门票数量:" + tickets);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        Thread t1 = new Thread(ticket);
        Thread t2 = new Thread(ticket);
        Thread t3 = new Thread(ticket);
        t1.start();
        t2.start();
        t3.start();
    }
}

运行结果:

当前剩余门票数量:9
当前剩余门票数量:8
当前剩余门票数量:7
当前剩余门票数量:6
当前剩余门票数量:5
当前剩余门票数量:4
当前剩余门票数量:3
当前剩余门票数量:2
当前剩余门票数量:1
当前剩余门票数量:0

同步监视器可以为任何一个对象,不过一般使用需要同步的对象或者字符串。每个对象都存在一个标识位,只有两个值01;一个线程运行到同步块时首先检查该对象的标识位,如果为0则同步块中已经存在其他线程正在处理,此时该线程会进入阻塞状态,等待同步块中的线程执行完毕时,标识位设置为1后,该线程才能执行同步块中的代码。并将Object对象的标识位设置为0,防止其他线程执行同步块中的代码。

2. 同步方法

关键字synchronized也可以修饰在方法上,被synchronized关键字修饰的方法称为同步方法,当一个对象中的同步方法被调用时,这个对象的其他同步方法必须等待被调用的同步方法执行完才能被执行。必须将每个能访问资源的方法都设置为同步方法。不然就无法保证线程安全。synchronized关键字可以修饰成员方法也可以修饰静态方法

将章节6.1中的售票系统的例子使用同步方法修改后:

public class Ticket extends Thread {
    private int tickets = 10;
    public synchronized void sell() {
        if (tickets > 0) {
            tickets--;
            try {
                sleep(100);
                System.out.println("当前剩余门票数量:" + tickets);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    @Override
    public void run() {
        while (true) {
            sell();
        }
    }
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        Thread t1 = new Thread(ticket);
        Thread t2 = new Thread(ticket);
        Thread t3 = new Thread(ticket);
        t1.start();
        t2.start();
        t3.start();
    }
}

运行结果:

当前剩余门票数量:9
当前剩余门票数量:8
当前剩余门票数量:7
当前剩余门票数量:6
当前剩余门票数量:5
当前剩余门票数量:4
当前剩余门票数量:3
当前剩余门票数量:2
当前剩余门票数量:1
当前剩余门票数量:0
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值