Java 多线程学习

Author Avatar
EmptinessBoy 5月 25, 2020
  • 在其它设备中阅读本文章

每个运行的程序都是一个进程,在一个进程中还可以有多个执行单元同时运行。这些执行单元可以看作程序执行的—条条线索,被称为线程。操作系统中的每—个进程中都至少存在—个线程。例如当一个 Java 程序启动时,就会产生—个进程,该进程中会默认创建一个线程,在这个线程上会运行 main() 方法中的代码。

创建线程

在 Java 中提供了两种多线程实现方式, —种是继承 java.lang 包下的 Thread 类,覆写 Thread 类的 run() 方法,在 run() 方法中实现运行在线程上的代码;另一种是实现 java.lang.Runnable 接口,同样是在 run() 方法中实现运行在线程上的代码。

直接继承 Thread

JDK 中提供了—个线程类Thread , 通过继承 Thread 类,并重写 Thread 类中的 run() 方法便可实现多线程。在 Thread 类中,提供了一个 start() 方法,用千启动新线程。线程启动后,虚拟机会自动调用 run() 方法,如果子类重写了,该方法便会执行子类中的方法。

public class TwoThread {
    public static void main(String[] args) {
        new PrintThread1("线程A").start();
        new PrintThread1("线程B").start();
    }
}
class PrintThread1 extends Thread{
    public PrintThread1(String name) {
        super(name);
    }

    @Override
    public void run() {;
        for (int i=0;i<1000;i++){
            System.out.println(this.getName()+" 第"+i+"次");
            try {
                sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }
}

运行效果:

TwoThread.png

实现 Runnable 接口

继承Thread 类实现了多线程,但是这种方式有—定的局限性。因为Java中只支持单继承,一个类一旦继承了某个父类就无法再继承Thread 类,例如学生类Student 继
承了Person 类,就无法通过继承Thread 类创建线程。

为了克服这种弊端, Thread 类提供了另外一个构造方法 Thread(Runnable target) ,其中,Runnable 是一个接口, 它只有—个 run() 方法。当通过 Thread(Runnable target))构造方法创建线程对象时,只需为该方法传递—个实现了 Runnable 接口的实例对象,这样创建的线程将调用实现了 Runnable 接口的类中的 run() 方法作为运行代码,而不需要调用 Thread 类中的 run() 方法

法A:创建多个线程对象

import static java.lang.Thread.sleep;

public class ThreadRunnable {
    public static void main(String[] args) {
        PrintThread2 xc1 = new PrintThread2("线程a");
        PrintThread2 xc2 = new PrintThread2("线程b");
        PrintThread2 xc3 = new PrintThread2("线程c");
        new Thread(xc1).start();
        new Thread(xc2).start();
        new Thread(xc3).start();
    }
}
class PrintThread2 implements Runnable{
    String name;
    public PrintThread2(String name) {
        this.name=name;
    }

    @Override
    public void run() {
        for (int i=0;i<1000;i++){
            System.out.println(this.name+" 第"+i+"次");
            try {
                sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

运行效果:

RunnableThread.png

法B:仅创建一个线程的对象(推荐)

这种方法,相比刚才的,可以在多个线程中同时使用线程对象 PrintThread3 中的某个变量。

import static java.lang.Thread.currentThread;
import static java.lang.Thread.sleep;

public class ThreadRunnablePlus {
    public static void main(String[] args) {
        PrintThread3 xc= new PrintThread3();
        //构造方法Thread(Runnable target, String name)在创建线程对象的同时指定线程的名称,
        new Thread(xc,"线程1").start();
        new Thread(xc,"线程2").start();
        new Thread(xc,"线程3").start();
    }
}

class PrintThread3 implements Runnable{
    int n=0;

    @Override
    public void run() {
        for (int i=0;i<1000;i++){
            //打印当前线程名和次数
            n++;
            System.out.println(currentThread().getName()+" 第"+i+"次,一共第"+n+"次");
            try {
                sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

运行效果:

RunnableThreadPlus-nolock.png

细心的童鞋,可能在这里就发现问题了,貌似总数统计出现的顺序有问题?yep,这个问题且听下面 线程同步 的小节分解。

线程同步

当多个线程去访问同一个资源时,也会引发一些安全问题。例如下面的代码段,我们把循环终止的条件改为 总次数n 小于等于10:

public class ThreadRunnablePlus {
    public static void main(String[] args) {
        PrintThread3 xc= new PrintThread3();
        new Thread(xc,"线程1").start();
        new Thread(xc,"线程2").start();
        new Thread(xc,"线程3").start();
    }
}

class PrintThread3 implements Runnable{
    int n=0;
    @Override
    public void run() {
        int i=0;
        for (;n<=10;n++){
            //打印当前线程名和次数
            i++;
            System.out.println(currentThread().getName()+" 第"+i+"次,一共第"+n+"次");
        }
    }
}

运行效果:

RunnableThreadPlus-nonelock0.png

按理说,在 n 为 10 的时候,应该结束所有线程。但事实上,在这之后,还有语句输出

像现在碰到的计数错误的情况就是因为多个线程同时访问对象中的变量 n 导致的。为了解决这样的问题,需要 实现多线程的同步 ,即限制某个资源在同一时刻只能被一个线程访问。

同步代码块

线程安全问题其实就是由多个线程同时处理共享资源所导致的。要想解决线程安全问题,必须得保证用于处理共享资源的代码在任何时刻只能有—个线程访问。

比如刚才例子中的这一段:

for (;n<=10;n++){
    //打印当前线程名和次数
    i++;
    System.out.println(currentThread().getName()+" 第"+i+"次,一共第"+n+"次");
}

语法规范

Java 中提供了同步机制。当多个线程使用同—个共享资源时,可以将处理共享资源的代码放在一个使用 synchronized 关键字来修饰的代码块中,这个代码块被称作同步代码块,

Object lock= new Object(); //定义任意一个对象,用作同步代码块的锁
synchronized (lock) {
    //操作共享资源代码块
}

实现案例

我们将刚才出现异常的代码做出如下改进,将循环套上同步锁 synchronized:

class PrintThread3 implements Runnable{
    int n=0;

    @Override
    public void run() {
        int i=0;
        synchronized (this){
            for (;n<=10;n++){
                //打印当前线程名和次数
                i++;
                System.out.println(currentThread().getName()+" 第"+i+"次,一共第"+n+"次");
            }
        }

    }
}

运行效果:

RunnableThreadPlus-locked.png

可以看到,后面的计数都完全正常了,可是线程2和3却没有运行。

这是因为因为整个循环体的代码块都被线程1阻塞了,等线程2和3得到同步锁的时候,n的值已经为11了,无法再次进入循环了。

因此,这里的同步锁应当放在循环体内,同时将 for 循环改为 while 循环可以很好的解决问题:

class PrintThread3 implements Runnable{
    int n=1;

    @Override
    public void run() {
        int i=0;
        while (true){
            i++;
            synchronized (this){
                if(n<=10){
                    //打印当前线程名和次数
                    System.out.println(currentThread().getName()+" 第"+i+"次,一共第"+n+"次");
                    n++;
                }else
                    break;
            }
            //为了演示,防止运行过快,加了这段延时代码
            try {
                sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}s

运行效果:

RunnableThreadPlus-locksuccess.png

同步方法

除了同步代码块,在方法前面同样可以使用 synchronized 关键字来修饰,被修饰的方法为同步方法, 它能实现与同步代码块相同的功能,具体语法格式如下。

synchronized 返回值类型 方法名([参数1 ,^ ]) {
    //方法体
}

被 synchronized 修饰的方法在某—时刻只允许一个线程访问,访问该方法的其他线程都会
发生阻塞,直到当前线程访问完毕后,其他线程才有机会执行该方法。

实现案例

这里我们将同一个案例改为使用方法同步来实现:

class PrintThread3 implements Runnable{
    int n=1;
    boolean fin = true;

    @Override
    public void run() {
        int i=0;
        while (fin){
            i++;
            doNum(i);
            //减慢运行速度便于观察
            try {
                sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    //方法锁
    synchronized void doNum(int i){
        if(n<=10){
            //打印当前线程名和次数
            System.out.println(currentThread().getName()+" 第"+i+"次,一共第"+n+"次");
            n++;
        }else
            fin=false;
    }
}

运行效果:

RunnableThreadPlus-methodlock.png

死锁问题

两个线程都需要对方所占用的锁,但是都无法释放自己所拥有的锁,于是这两个线程都处于挂起状态,从而造成了死锁。

public class SyncDie implements Runnable{
    static Object a = new Object();
    static Object b = new Object();
    boolean flag;

    @Override
    public void run() {
        if(flag){
            while (true){
                synchronized (a) {
                    System.out.println("a1");
                    synchronized (b) {
                        System.out.println("b1");
                    }
                }
            }
        }
        else{
            while (true){
                synchronized (b) {
                    System.out.println("b2");
                    synchronized (a) {
                        System.out.println("a2");
                    }
                }
            }
        }
    }

    public SyncDie(boolean flag) {
        this.flag = flag;
    }

    public static void main(String[] args) {
        SyncDie s1 = new SyncDie(true);
        SyncDie s2 = new SyncDie(false);
        new Thread(a).start();
        new Thread(b).start();
    }
}

运行效果:

synchronize-die.png

可以看到,两个线程一直在运行并没有退出,因为线程 s1 先锁定了 a,线程 s2 锁定了 b;然后 s1 要获取 b 才能继续运行并释放 a,但是 s2 只有等 s1 释放 a 了,才能继续运行然后释放 b……

听起来很绕,没错,这就像套娃。(拒绝套娃,从不写死锁开始。

生命周期

在 Java 中,任何对象都有生命周期,线程也不例外,它也有自己的生命周期。当 Thread
对象创建完成时,线程的生命周期便开始了。当 run() 方法中代码正常执行完毕或者线程抛出一个未捕获的异常 (Exception) 或者错误 (Error) 时,线程的生命周期便会结束。

几个阶段

线程整个生命周期可以分为 5 个阶段,分别是新建状态 (New)、就绪状态 (Runnable)、运行状态 (Running)、阻塞状态 (Blocked) 和死亡状态 (Terminated) ,线程的不同状态表明了线程当前正在进行的活动。在程序中,通过—些操作,可以使线程在不同状态之间转换,

ThreadLifeTime.png

新建状态(New)

创建—个线程对象后,该线程对象就处千新建状态,此时它不能运行,和其他Java 对象一
样,仅仅由Java 虚拟机为其分配了内存,没有表现出任何线程的动态特征。

就绪状态(Runnable)

当线程对象调用了 start() 方法后,该线程就进入就绪状态。处于就绪状态的线程位于线程队列中,此时它只是具备了运行的条件,能否获得 CPU 的使用权并开始运行,还需要等待系统的调度。

运行状态(Running)

如果处于就绪状态的线程获得了 CPU 的使用权,并开始执行 run() 方法中的线程执行体,则该线程处于运行状态。

阻塞状态(Blocked)

—个正在执行的线程在某些特殊情况下,如被人为挂起或执行耗时的输入/输出操作时,会
让出CPU 的使用权并暂时中止自己的执行,进入阻塞状态。线程进入阻塞状态后,就不能进入排队队列。只有当引起阻塞的原因被消除后,线程才可以转入就绪状态。

获取某个对象的同步锁;调用了一个阻塞式的 IO 方法;调用了某个对象的 wait() 方法【需要使用 notify() 方法唤醒该线程】;程调用了 Thread 的 sleep(long miles) 方法;调用了另—个线程的 join() 方法【需要等待被插入的线程执行完毕】

这些都会导致当前线程被阻塞

死亡状态(Terminated)

当线程调用 stop() 方法【stop已弃用】或 run() 方法正常执行完毕后,或者线程抛出一个未捕获的异常 (Exception)、错误(Error),线程就进入死亡状态。一旦进入死亡状态,线程将不再拥有运行的资格,也不能再转换到其他状态。(就是废了)

线程的调度

在计算机中,线程调度有两种模型,分别是分时调度模型和抢占式调度模型。

分时调度模型: 让所有的线程轮流获得CPU 的使用权,并且平均分配每个线程占用的CPU 的时间片。

抢占式调度模型 (JAVA): 让可运行池中优先级高的线程优先占用 CPU, 而对于优先级相同的线程,随机选择—个线程使其占用 CPU, 当它失去了 CPU 的使用权后,再随机选择其他线程获取 CPU使用权。

线程的优先级

Thread 类的静态常量功能描述
static int MAX_ PRIORITY表示线程的最高优先级,值为10
static int MIN_PRIORITY表示线程的最低优先级, 值为1
static int NORM_PRIORITY表示线程的晋通优先级, 值为5

可以通过Thread 类的 setPriority(int newPriority) 方法对其进行设置,该方法中的参数 newPriority 接收的是 1~10 之间的整数或者 Thread 类的 3 个静态常量。

样例:

public class TwoThread {
    public static void main(String[] args) {
        Thread a = new PrintThread1("线程A");
        Thread b = new PrintThread1("线程B");
        a.setPriority(Thread.MIN_PRIORITY);
        b.setPriority(Thread.MAX_PRIORITY);
        a.start();
        b.start();
    }
}
class PrintThread1 extends Thread{
    public PrintThread1(String name) {
        super(name);
    }

    @Override
    public void run() {
        for (int i=0;i<10;i++){
            System.out.println(this.getName()+" 第"+i+"次");
        }

    }
}

运行效果:

ThreadsetPriority.png

可以看到,在设置了优先级后,线程B比线程A更早运行。当然这并不代表A只能等B运行完了才能运行。只是开始时间B会早于A,很有可能A会在B阻塞时启动。

注意:不同的操作系统对优先级的支持是不—样的, 不会与 Java 中线程优先级——对应。因此,在设计多线程应用程序时,其功能的实现一定不能依赖于线程的优先级,而只能把线程优先级作为一种提高程序效率的手段。

线程休眠

如果希望人为地控制线程,使正在执行的线程暂停,将 CPU 让给别的线程,这时可以使用静态方法 sleep(long millis),该方法可以让当前正在执行的线程暂停一段时间,进入休眠等待状态。

Sleep(long millis) 方法声明会抛出 Interrupted Exception 异常,因此在调用该方法时应该捕获异常,或者声明抛出该异常。

调用格式如下:

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

需要注意的是, sleep() 是静态方法,只能控制当前正在运行的线程休眠,而不能控制其他线程休眠。当休眠时间结束后,线程就会返回到就绪状态,而不是立即开始运行。

线程让步

所谓的线程让步是指正在执行的线程,在某些情况下将 CPU 资源让给其他线程执行。

线程让步可以通过 yield() 方法来实现,该方法和 sleep() 方法有点相似,都可以让当前正在运行的线程暂停,区别在于 yield() 方法不会阻塞该线程,它只是将线程转换成就绪状态,让系统的调度器重新调度一次。当某个线程调用 yield() 方法之后,只有与当前线程优先级相同或者更高的线程才能获得执行的机会。

样例:

public class TwoThread {
    public static void main(String[] args) {
        Thread a = new PrintThread1("线程A");
        Thread b = new PrintThread1("线程B");
        b.start();  //线程b先启动
        a.start();
    }
}
class PrintThread1 extends Thread{
    public PrintThread1(String name) {
        super(name);
    }

    @Override
    public void run() {
        //super.run();
        for (int i=0;i<10;i++){
            System.out.println(this.getName()+" 第"+i+"次");
            if(this.getName().equals("线程B")){
                System.out.println("线程B让步");
                this.yield();
            }
        }

    }
}

运行效果:

Thread-yield.png

可以看到每次让步后,线程A就会排在前面执行。多次让步最后线程A率先运行完成。

线程插队

在 Thread 类中也提供了一个 join() 方法来实现这个“功能” 。当在某个线程中调用其他线程的 join() 方法时,调用的线程将被阻塞,直到被 join() 方法加入的线程执行完成后它才会继续运行。

样例:

public class TwoThread {
    public static void main(String[] args) {
        Thread a = new PrintThread1("线程A");
        a.start();
        for (int i=0;i<10;i++){
            System.out.println("Main方法 第"+i+"次");
            if(i==3) {
                try {
                    System.out.println("a插队进来了");
                    a.join();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }


    }
}
class PrintThread1 extends Thread{
    public PrintThread1(String name) {
        super(name);
    }

    @Override
    public void run() {
        //super.run();
        for (int i=0;i<5;i++){
            System.out.println(this.getName()+" 第"+i+"次");
        }

    }
}

运行效果:

ThreadJoin.png

可以看到,线程A插队进来后,Main方法必须等线程A执行完毕才能继续执行。

线程协作关系

当多个线程为完成同一任务而分工协作时,它们彼此之间有联系,知道其他线程的存在,而且受其他线程执行的影响,这些线程间存在协作关系。

协作线程之间相互等待以协调进度的过程被称为线程同步。两个以上线程基于某个条件来协调它们的活动。一个线程的执行依赖于另一个协作线程的消息或信号,当线程没有得到来自于另一个线程的消息或信号时需等待,直到消息或信号到达才被唤醒。

wait() 和 notify()

//线程同步:

wait()  //挂起一个线程
notify()    //唤醒线程
notifyAll() //唤醒全部线程

wait()方法: 该方法用来使得当前线程进入等待状态,直到接到通知或者被中断打断为止。在调用wait()方法之前,线程必须要获得该对象的对象级锁;换句话说就是该方法只能在同步方法或者同步块中调用,如果没有持有合适的锁的话,线程将会抛出异常 IllegalArgumentException。 调用wait()方法之后,当前线程则释放锁。

notify()方法: 该方法用来唤醒处于等待状态获取对象锁的其他线程。如果有多个线程则线程规划器任意选出一个线程进行唤醒,使其去竞争获取对象锁,但线程并不会马上就释放该对象锁,wait() 所在的线程也不能马上获取该对象锁,要程序退出同步块或者同步方法之后,当前线程才会释放锁,wait() 所在的线程才可以获取该对象锁。

注意事项

  • wait() 方法是释放锁的;notify() 方法不释放锁,必须等到所在线程把代码执行完。

  • 由于 notify() 唤醒了一个随机线程,因此它可用于实现线程执行类似任务的互斥锁定,但在大多数情况下,实现 notifyAll() 会更可行。

  • 建议使用 while循环 搭配一个公用的 boolean flag 食用更香!

线程停止

为什么弃用 stop 和 suspend

初始的 java 版本中定义了一个 stop 方法来终止一个线程还定义了一个 suspend 方法来阻塞一个线程,直到另一个线程调用 resume 方法。这两个方法在 Java SE 1.2 之后就被弃用了,因为这两种方法都不安全。

stop 方法天生就不安全,因为它在终止一个线程时会强制中断线程的执行,不管 run 方法是否执行完了,并且还会释放这个线程所持有的所有的锁对象。这一现象会被其它因为请求锁而阻塞的线程看到,使他们继续向下执行。这就会造成数据的不一致。

suspend 被弃用的原因是因为它会造成死锁。suspend 方法和 stop 方法不一样,它不会破换对象和强制释放锁,相反它会一直保持对锁的占有,一直到其他的线程调用 resume 方法,它才能继续向下执行。

正确的终止一个线程

可以采用设置一个条件变量的方式,run 方法中的 while 循环会不断的检测 flag 的值,在想要结束线程的地方将 flag 的值设置为 false 就可以啦!

注意这里要将 flag 设置成 volitale 的,因为 volitale 可以保证数据的有效性,如果不设置话,可能会造成子线程多执行一次的错误

补充

网络资料:

volatile 的作用就是把放在线程栈上的变量立刻同步到主内存中,这里涉及到 Java 内存模型的知识。

每一个运行在Java虚拟机里的线程都拥有自己的线程栈。这个线程栈包含了这个线程调用的方法当前执行点相关的信息。一个线程仅能访问自己的线程栈。一个线程创建的本地变量对其它线程不可见,仅自己可见。即使两个线程执行同样的代码,这两个线程任然在在自己的线程栈中的代码来创建本地变量。因此,每个线程拥有每个本地变量的独有版本。

所有原始类型的本地变量都存放在线程栈上,因此对其它线程不可见。一个线程可能向另一个线程传递一个原始类型变量的拷贝,但是它不能共享这个原始类型变量自身。

多个线程间变量的同步都是先同步到主内存中,然后主内存再同步到其他线程的变量副本上,但无法保证同步是立刻执行的,加 volatile 之后会立刻同步。

This blog is under a CC BY-NC-ND 4.0 Unported License
本文链接:https://coding.emptinessboy.com/2020/05/Java-%E5%A4%9A%E7%BA%BF%E7%A8%8B%E5%AD%A6%E4%B9%A0/