Java多线程编程详解

Java多线程编程详解

1.多线程的相关概念

在这里插入图片描述
进程(Process)

进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。 在当代面向线程设计的计算机结构中,进程是线程的容器程序是指令、数据及其组织形式的描述,进程是程序的实体

线程(thread)

线程是操作系统中CPU能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

多线程

在单一程序中想同时运行完成多个不同的工作,称为多线程。
 
但是多线程通常情况下不是真的是同时在运行的,因为在进程得到系统分配的处理器资源之后,进程中的调度程序会选择一个线程来运行,并且进程中同一时刻只能有一个线程运行。可以理解成系统只允许一个线程执行1秒,下一秒换另一个线程,不断这样切换,所以只是系统处理的速度非常的快,让我们在视觉上感觉是同时运行的。
 
在一个进程中,如果开辟了多个线程,线程的运行是由调度器(CPU)安排调度的,调度器是与操作系统紧密相关的, 先后顺序是不能人为干预的。所以当多个线程对同一个资源进行操作时,会存在资源抢夺的问题,需要加入并发控制。另外,线程会带来额外的开销,如CPU调度时间和并发控制开销等。

2.Java中创建线程

在Java中,程序运行时,即使我们没有自己创建线程,后台也会有多个线程,如主线程main()函数,和GC(Gabage Collection)垃圾回收机制线程等。JDK8官方文档对线程的介绍如下:

线程是程序中执行的线程。 Java虚拟机允许应用程序同时执行多个执行线程。
 
每个线程都有优先权。 具有较高优先级的线程优先于优先级较低的线程执行。 每个线程可能也可能不会被标记为守护程序。 当在某个线程中运行的代码创建一个新的Thread对象时,新线程的优先级最初设置为等于创建线程的优先级,并且当且仅当创建线程是守护进程时才是守护线程。
 
当Java虚拟机启动时,通常有一个非守护进程线程(通常调用某些指定类的名为main的方法)。 Java虚拟机将继续执行线程,直到发生以下任一情况:

  • 已经调用了Runtime类的exit方法,并且安全管理器已经允许进行退出操作。
  • 所有不是守护进程线程的线程都已经死亡,无论是从调用返回到run方法还是抛出超出run方法的run 。

Java中创建一个新的执行线程有两种方法。 一个是将一个类声明为Thread的子类;另一种方法来创建一个线程是声明一个类实现Runnable接口。
 
每个线程都有一个用于识别目的的名称。 多个线程可能具有相同的名称。 如果在创建线程时未指定名称,则会为其生成一个新名称。 除非另有说明,否则将null作为参数传递给构造函数或方法将导致抛出NullPointerException

2.1 继承Thread类

Java中创建一个线程可以将一个类声明为Thread的子类。 这个子类应该重写Thread类的run方法。 然后可以分配并启动这个子类实例。

public class MyThread1 extends Thread{
   
    @Override
    public void run() {
   
        //run方法线程体
        for (int i = 0; i < 200; i++) {
   
            System.out.println("我在学习多线程"+i);
        }
    }

    public static void main(String[] args) {
   
        //创建一个线程对象
        MyThread1 thread1 = new MyThread1();
        //调用start方法开启一个新线程
        thread1.start();
        //main主线程执行
        for (int i = 0; i < 2000; i++) {
   
            System.out.println("我在跑代码"+i);
        }
    }
}

运行结果如下,可以看到在主线程中开启一个新的子线程,两个线程同时一起执行,所以在“我在跑代码”中间插入输出了“我在学习多线程~”。虽然在逻辑上两个线程是同时运行的,但新线程不一定立即执行,一切由CPU来调度,上述也提到过多线程是伪多线程,在逻辑上我们是可以这样看的。

另外,由于CPU的处理速度太快,大多数情况下看到的打印结果可能是一个线程先执行完,再执行另一个线程;但实际上确实是两个线程同时在执行。可以尝试增加打印字符串的量并多次运行,来查看结果。
在这里插入图片描述
这种创建线程的方式不推荐使用,Java是单继承的,如果采用继承Thread的方式实现多线程,则不能继承其他的类,存在单继承限制。

2.2 实现Runable接口

Java中另一种创建一个线程的方法是声明一个类实现Runnable接口,并实现run方法。 然后就可以分配该类的实例,并在创建线程时将该实例作为Thread类的参数传递,最后并启动。

public class MyRunnable1 implements Runnable{
   
    @Override
    public void run() {
   
        //run方法线程体
        for (int i = 0; i < 200; i++) {
   
            System.out.println("我在学习多线程~"+i);
        }
    }

    public static void main(String[] args) {
   
        //创建一个Runnable接口实现类的实例对象
        MyRunnable1 runnable1 = new MyRunnable1();
        //将runnable1作为Thread类的参数启动
        Thread thread = new Thread(runnable1);
        thread.start();
        //main主线程执行
        for (int i = 0; i < 10; i++) {
   
            System.out.println("我在跑代码"+i);
        }
    }
}

上述创建线程的方式和直接继承Thread的方式运行得到的结果相同。并且通常情况下推荐使用这种方式来创建线程,避免了单继承局限性的问题,灵活方便,可以实现一个实现了Runnable接口的对象被多个线程使用:

//示例演示多个线程同时操作同一个对象
//场景:买火车票
public class TestRun implements Runnable{
   
    //初始有10张票
    private int ticketNums = 10;

    @Override
    public void run() {
   
        while (true){
   
            if (ticketNums<=0) {
   
                break;
            }
            System.out.println(Thread.currentThread().getName() + "买到第" + ticketNums-- + "张票!");
        }
    }

    public static void main(String[] args) {
   
        TestRun testRun = new TestRun();
        new Thread(testRun,"小明").start();
        new Thread(testRun,"小红").start();
        new Thread(testRun,"黄牛").start();
    }
}

运行结果如下,可以发现三个进程同时运行,一起在买票,上述代码中的Thread.currentThread().getName()为获取前文提到的线程的名字,我们可以在创建线程时通过构造方法传参。

但是通过运行结果容易发现一个问题,两个不同的人买到了同一张票,即多个线程在操作同一个资源private int ticketNums = 10;票数时,数据发生了紊乱。这种情况在实际应用场景中,显然是不允许出现的。这种又并发导致的问题叫做 线程不安全,并发问题将在后文详细解释。
在这里插入图片描述

2.3 实现Callable接口

上述两种创建线程的方式都有一个共同的弊端,就是线程run方法没有返回值,如果一个线程需要有返回值时,则可以采用实现Callable接口,并实现call方法来实现多线程。另外,Callable接口接受一个泛型作为接口中call方法的返回值类型,因此我们在使用时需要传入一个返回值类型,如果不传入则默认返回Object类型。

采用实现Callable接口实现多线程启动方式和之前两种的方式不太一样:

  • 首先需要创建一个实现了Callable接口的类的实例;
  • 然后将该实例对象以构造参数的形式注册进入到FutureTask类中;
  • 然后再将FutureTask类的实例以构造参数的形式传给Thread
  • 最后可以采用FutureTask<V>中的get方法获取线程的返回值。
public class MyCallable implements Callable<Integer> {
   

    private int n = 0;

    MyCallable(int n){
   
        this.n = n;
    }

    @Override
    public Integer call() throws Exception {
   
        //实现0~n的整数的累加
        int sum = 0;
        for (int i = 0; i <= n; i++) {
   
            System.out.println("新线程累加到了整数:"+i);
            sum += i;
        }
        //返回累加的和
        return sum;
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
   
        int n = 2000;
        MyCallable myCallable = new MyCallable(n);
        FutureTask<Integer> futureTask = new FutureTask<>(myCallable);
        //注意,如果在两个线程里传入同一个futureTask,只会执行一次;所以有多少个线程,就应该new 多少个 FutureTask
        new Thread(futureTask).start();
        int sum = 0;
        for (int i = 0; i <= n; i++) {
   
            System.out.println("main线程累加到了整数:"+i);
            sum += i;
        }
        System.out.println("新线程的累加结果为:"+futureTask.get()+"========main线程的累加结果为:"+sum);

    }
}

在这里插入图片描述
可以发现,无论我们以怎样的形式实现多线程,都需要调用Thread类中的start方法去向操作系统请求IO,CPU等资源,在Thread中有一个构造方法是传入一个Runnable接口类型的参数,而我们上述使用的FutureTask<V>实例实现了 RunnableFuture<V>接口,而 RunnableFuture<V>接口又继承了Runnable接口和Funture<V>接口,因此我们可以将FutureTask<V>的一个实例当做是一个Runnable接口的实例传入Thread来启动我们新建的线程。本质上,Java中线程的采用的是 静态代理的设计模式,我们只需要关心线程要做的事,然后交给Thread这个代理人,由它为我们完成其他完成一个线程需要的工作。
在这里插入图片描述

3.线程的状态

3.1 线程的5大状态介绍

一个线程执行命令、完成任务的一生中,有多个状态:
在这里插入图片描述
上述线程的几种状态之中,除了就绪状态和阻塞状态,其他状态都比较好理解。在上文中我们知道,线程的执行取决于CPU的调度,即是否有获取到CPU资源来保证自己程序执行过程中需要进行的运算操作。而这两种状态就是与CPU调度相关的状态:

就绪状态:

一个新创建的线程并不自动开始运行,要执行线程,必须得先调用线程的start()方法。当线程对象调用start()方法启动了线程后,start()方法会创建线程运行需要的系统资源,并调度线程去运行线程对象的run()方法。当start()方法返回后,线程就处于就绪状态。
 
但是处于就绪状态的线程并不一定会立即运行run()方法,因为线程还必须同其他处于就绪状态的线程竞争CPU资源,只有获得CPU资源的线程才可以运行。对多个处于就绪状态的线程是由Java运行时系统的线程调度程序(thread scheduler)来调度的。

阻塞状态:

阻塞状态即被CPU调度过程中处于被阻拦的状态,不允许参与到获取CPU的资源过程中。线程运行过程中,可能由于如下原因进入阻塞状态:
 
1.线程通过调用sleep方法进入睡眠状态;
2.线程调用一个在I/O上被阻塞的操作,即该操作在输入输出操作完成之前不会返回到它的调用者;
3.线程试图得到一个锁,而该锁正被其他线程持有;
4.线程在等待某个触发条件;
 
但是阻塞状态并不表示线程已经运行结束,而是正在运行的线程暂时让出CPU,这时其他处于就绪状态的线程就可以获得CPU资源,进入运行状态。

对于终止线程,Java中不推荐自己JDK提供的一些方法,一般是推荐线程自己停下来,我们可以通过设置一个标志位作为终止变量,让程序满足条件时线程停下来。其实可以看到上述的示例中,基本都是采用的这种方式来结束线程。

另外,Java对于线程状态的控制和操作,Thread类提供了一些方法如下:

Thread类对象调用的方法 方法描述
public void start() 使该线程开始执行;Java 虚拟机调用该线程的 run 方法
public void run() 如果该线程是使用独立的 Runnable 运行对象构造的,则调用该 Runnable 对象的 run 方法;否则,该方法不执行任何操作并返回
public final void setName(String name) 改变线程名称,使之与参数 name 相同
public final void setPriority(int priority) 更改线程的优先级
public final void setDaemon(boolean on) 将该线程标记为守护线程或用户线程
public final void join(long millisec) 中断当前正在执行的线程 。参数非必须,表示等待该线程终止的时间最长为 millis 毫秒
public void interrupt() 中断线程
public final boolean isAlive() 测试线程是否处于活动状态
public State getState() 获取线程当前的状态

上述方法是被Thread对象调用的;下面的方法是Thread类的静态方法。

Thread类的静态方法 方法描述
public static void yield() 暂停当前正在执行的线程对象,并执行其他线程
public static void sleep(long millisec) 在指定的毫秒数内让当前正在执行的线程休眠(暂停执行),此操作受到系统计时器和调度程序精度和准确性的影响
public static boolean holdsLock(Object x) 当且仅当当前线程在指定的对象上保持监视器锁时,才返回 true
public static Thread currentThread() 返回对当前正在执行的线程对象的引用
public static void dumpStack() 将当前线程的堆栈跟踪打印至标准错误流

3.2 sleep方法改变线程状态

上述表格中对各个方法都有描述,比如sleep(long millisec)方法的作用是使线程进入一定时间的睡眠,即暂时的中断线程运行,让出CPU资源,使 线程进入阻塞状态,睡眠时间结束后再次进入就绪状态;调用该方法线程进睡眠后不会释放数据的同步锁,该内容属于线程同步部分,后文将会介绍。我们可以通过模拟龟兔赛跑来感受这个线程睡眠方法的简单应用:

//龟兔赛跑,模拟一个赛道就是一个线程,赛程共100米
//乌龟一直跑,兔子跑一会就睡一会,通过Thread.sleep实现睡觉。
public class Track implements Runnable{
   
    //表示是否有人冲线,产生冠军
    private boolean winner = false;

    //跑步线程
    @Override
    public void run() {
   
        //已经跑了的距离
        int distance = 0;
        while (distance<100) {
   
            //如果已经产生了冠军,则不用再跑了
            if (winner){
   
                break;
            }
            System.out.println(Thread.currentThread().getName()+"跑了"+ distance++ +"米!");
            //兔子跑10步则在路边睡10毫秒
            if (Thread.currentThread().getName().equals("兔子") && distance%10==0){
   
                try {
   
                    Thread.sleep(10);
                } catch (InterruptedException e) {
   
                    e.printStackTrace();
                }
            }
        }
        //跑完100米则表示冲线
        winner = true;
        //宣布跑完100米冲线的冠军得主
        if (distance==100){
   
            System.out.println(Thread.currentThread().getName()+"已经冲线~获得本次跑步比赛的冠军!!!!");
        }
    }

    public static void main(String[] args) {
   
        Track track = new Track();
        new Thread(track,"兔子").start();
        new Thread(track,"乌龟").start();
    }
}
/* 运行结果: ... 兔子跑了8米! 兔子跑了9米! 乌龟跑了96米! 乌龟跑了97米! 乌龟跑了98米! 乌龟跑了99米! 乌龟已经冲线~获得本次跑步比赛的冠军!!!! */

由于兔子🐰在跑步过程中睡觉,所以运行结果无论怎样都是乌龟🐢获胜(CPU运算太快了,兔子睡那儿一会,乌龟就跑完了。。😄)。

3.3 yield方法改变线程状态

又如yield()方法,作用也是暂停线程,但是该方法和sleep()方法有本质的不同,该方法虽然也是将CPU资源让出去,但是 调用该方法的线程不是进入阻塞状态,而且跳过自己的本次的运行状态,直接进入就绪状态,让CPU进行重新调度。相当于一种礼让,但是礼让后还是在和其他线程一起竞争CPU资源,所以礼让不一定成功,因为这取决于CPU的调度,很可能CPU下个回合就还是调度到该线程。该方法和sleep一样不释放同步锁。

3.4 join方法改变线程状态

再如public final void join(long millisec)方法,表示中断当前正在执行的线程,且仅作用于正在执行的线程,使正在执行的线程进入阻塞状态,而调用该方法的线程则直接进入运行状态,待调用该方法的线程执行完毕后,之前被阻塞的线程再进入就绪状态。相当于调用该方法的线程插队了。

public class JoinTest{
   

    public static void main(String[] args) throws InterruptedException {
   
        //Lambda语法
        Thread thread = new Thread(()->{
   
            for (int i = 0; i < 3; i++) {
   
                System.out.println("VIP来了,让我先买票!"+i);
            }
        },"VIP们");
        thread.start();
        for (int i = 0; i < 5; i++) {
   
            if (i==3){
   
                thread.join();
            }
            System.out.println("人们正在排队买票~"+i);
        }
    }
}
/* 运行结果: 人们正在排队买票~0 人们正在排队买票~1 人们正在排队买票~2 VIP来了,让我先买票!0 VIP来了,让我先买票!1 VIP来了,让我先买票!2 人们正在排队买票~3 人们正在排队买票~4 Process finished with exit code 0 */

3.5 更改线程优先级

Java提供一个线程调度器来监控Java程序启动后进入就绪状态的所有线程,线程调度器按照优先级决定应该调度哪个线程进入运行状态。

但是线程的执行本质还是在于CPU的调度,所以线程优先级高的也不一定会优先执行。 但是优先级高的线程其权重会更大,Java线程调度器分配给该线程的资源就更多,获得CPU资源的概率就更大。就像买彩票,买1张彩票和买100张彩票,虽然买1张彩票也可能中奖,但显然买100张彩票的中奖概率更高。

使用Thread的实例对象调用setPriority(int priority)getPriority()两个方法可以设置或获取线程的优先级,最大优先级为10,最小为1

public class PriorityTest {
   
    public static void main(String[] args) {
   
        Thread thread1 = new Thread(() -> System.out.println("当前线程优先级:"+Thread.currentThread().getPriority()));
        Thread thread2 = new Thread(() -> System.out.println("当前线程优先级:"+Thread.currentThread().getPriority()));
        Thread thread3 = new Thread(() -> System.out.println("当前线程优先级:"+Thread.currentThread().getPriority()));
        Thread thread4 = new Thread(() -> System.out.println("当前线程优先级:"+Thread.currentThread().getPriority()));
        thread1.setPriority(Thread.MIN_PRIORITY);
        thread3.setPriority(Thread.NORM_PRIORITY);
        thread2.setPriority(Thread.MAX_PRIORITY);
        thread4.setPriority(8);
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
        System.out.println("main线程优先级:"+Thread.currentThread().getPriority());
    }
}

3.6 用户线程和守护线程

Java中创建的线程分为 用户线程守护线程 。用户线程是默认的线程,上文我们创建的所有线程都为用户线程;而守护线程顾名思义则是在后台守护我们程序正常运行的线程。Java虚拟机必须确保用户线程执行完毕,而不用等待守护线程执行完毕,即所有用户线程执行结束则守护线程也结束。如后台的日志线程、内存监控线程和垃圾回收机制(GC)线程等。

使用Thread的实例对象调用setDaemon(boolean on)方法可以将该线程设置为守护线程还是用户线程:

public class DaemonTest {
   
    public static void main(String[] args) {
   
        Thread godThread = new Thread(() -> {
   
            while (true){
   
                System.out.println("我是上帝God!我看着你~守护着你~");
            }
        });
        //将该线程设置为守护线程,默认为false表示用户线程
        godThread.setDaemon(true);
        Thread myselfThread = new Thread(() -> {
   
            System.out.println("我出生了~Hello World!");
            System.out.println("我长大了~Working!");
            System.out.println("我屎了~Goodby World!");
        });
        //启动两个线程
        godThread.start();
        myselfThread.start();
    }
}
/* 运行结果: ······· 我是上帝God!我看着你~守护着你~ 我是上帝God!我看着你~守护着你~ 我是上帝God!我看着你~守护着你~ 我是上帝God!我看着你~守护着你~ 我是上帝God!我看着你~守护着你~ 我出生了~Hello World! 我长大了~Working! 我屎了~Goodby World! 我是上帝God!我看着你~守护着你~ 我是上帝God!我看着你~守护着你~ Process finished with exit code 0 */

上述示例程序中,守护线程为死循环,按理应该永远执行,但是运行结果显示,用户线程运行结束后,守护线程也结束了。

4.多线程同步

在现实生活中,我们会遇到“同一个资源,多个人都想使用”这样的问题,比如食堂排队打饭,每个人都想吃饭,但只有一个窗口,最常见的解决办法就是排队,一个一个来~
在这里插入图片描述

4.1 多线程同步原理

在多线程中,我们也会遇到与上述类似的问题,即多个线程同时想要操作同一个资源而引发的问题。多个线程间共享的这个数据我们称为 共享资源或临界资源,由于是CPU负责线程的调度,程序员无法精确控制多线程的交替顺序。所以在这种情况下,多线程对临界资源的访问有时会导致数据的不一致性。但是,在处理多线程的这种问题时有一种和排队类似的方法叫做:多线程同步!

多线程同步其实就是一种等待机制,多个需要同时访问同一个对象的线程首先进入这个对象的等待池形成队列,等待前面的线程使用完毕,下一个程序再使用。

但是有了队列还不够。比如排队上厕所,如果后面排队的人实在憋不住了,直接闯进来和你抢厕所怎么办,所以一般情况下厕所都有门,进去上厕所的人可以把门反锁,上完厕所开门出来后下一个人才可以进去如厕。把这样的情况映射到多线程同步中:

多线程同步机制为这些资源对象加上一把“互斥锁”(排他锁),保证在任何时刻只能由一个线程访问,即使该线程出现阻塞,该对象的被锁定状态也不会解除,其他线程仍不能访问该对象。即每一个线程在访问资源时,会将资源上锁;使用完后释放锁,这时下一个线程才能去使用该资源,否则其他任何线程都不能使用。(对于锁🔒的理解:可以看成是有轮番拥有这个锁的钥匙;也可以看成是谁拥有这个锁,谁才能去使用这个资源,相当于一个凭证。后一种理解虽然不太符合逻辑,但很多人经常这样描述)

综合上述对多线程 队列+锁🔒 的机制,这就是 多线程同步。多线程同步是保证线程安全的重要手段,但是多线程同步客观上也会导致性能下降问题,如:

  • 一个线程持有锁会导致其他所有需要使用该资源的线程被挂起,一直等待。
  • 在多线程竞争下,加锁,释放锁会导致进行比较多的上下文切换和调度延时,导致性能下降。
  • 如果一个优先级高的线程等待一个优先级低的线程释放锁,会导致优先级倒置,引起性能问题。

4.2 synchronized同步锁

为了控制多线程同步,Java中提供了一个关键字:synchronized!该关键字的作用是在多线程的环境下,控制被声明为synchronized的代码段不会被多个线程同时执行。synchronized是一种同步锁,每个线程调用被声明为synchronized的方法都必须获得该方法的锁才能执行,否则线程会阻塞;该方法一旦执行,线程就独占该方法的锁,直到该方法执行完毕返回才释放锁,后面被阻塞的线程才能获得这个锁,继续调用。synchronized关键字的声明的方式有以下几种:

  1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
  2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
  3. 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是使用这个类的方法的所有对象;
  4. 修饰一个类,其作用的范围是synchronized后面的代码块,即整个类,作用的对象是使用这个类的方法的所有对象。
synchronized(this) {
     }	//1.修饰代码块
public synchronized void run() {
     } 	//2.修饰方法
public static synchronized void testMethod() {
     } 	//3.修饰静态方法
synchronized(类名.class) {
     }	//4.修饰一个类

修饰代码块和修饰方法的这两种方式统称为:对象锁 🔒
修饰静态方法和修饰类的这两种方式统称为:类锁 🔒

在Java中由于我们可以通过private私有关键字来保证数据对象只能被方法访问,所以通过将synchronized声明在方法上就间接声明在了成员变量上,因此synchronized不允许声明在成员变量上。

另外如果将很多一部分代码声明为synchronized则将会影响程序执行效率,所以一般只会对修改数据的操作加锁,读数据的操作没必要加锁。

多人排队上厕所的示例如下:

public class SynchronizedTest{
   

    public static void main(String[] args) {
   
        Runnable runnable = new Runnable() {
   
            @Override
            public void run() {
   
                synchronized (this){
   
                    System.out.println(Thread.currentThread().getName()+"进入厕所!");
                    for (int i = 1; i <= 2; i++) {
   
                        System.out.println(Thread.currentThread().getName()+"已经如厕了"+i+"分钟...");
                    }
                    System.out.println(Thread.currentThread().getName()+"从厕所出来了!");
                }
            }
        };
        new Thread(runnable,"小明").start();
        new Thread(runnable,"小红").start();
        new Thread(runnable,"小王").start();
    }
}

虽然三个人同时去上厕所,但是运行结果一定是一个人用完厕所后才能另一个人去使用;如果去掉synchronized关键字的修饰,运行结果会出现多个人同时“进入厕所”,同时如厕的情况。。。

4.3 死锁

死锁是指多个线程互相持有着对方所需要的共享资源,导致这多个线程互相都在等待对方释放资源,进而导致这些线程全部阻塞、中断。当某一个同步块同时拥有2个以上的共享资源时,就可能会发生死锁问题。下述通过一个模拟化妆的示例来感受死锁是什么样的情况:

public class DeadLockTest {
   
    public static void main(String[] args) {
   
        Makeup makeup = new Makeup();
        new Thread(makeup,"小红").start();
        new Thread(makeup,"小花").start();
    }
}

//化妆线程,模拟一个梳妆台。
// 通过类锁模拟梳妆台上只有一面镜子和一个口红,但化妆需要同时拥有镜子和口红才能完成。
class Makeup implements Runnable{
   
    private class Mirror{
    };//镜子类
    private class Lipstick{
    };//口红类

    @Override
    public void run() {
   
        if (Thread.currentThread().getName().equals("小红")){
   
            synchronized (Mirror.class){
   
                System.out.println("小红拿到了镜子,现在在找口红...");
                try {
   
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
   
                    e.printStackTrace();
                }
                synchronized (Lipstick.class){
   
                    System.out.println("小红又拿到了口红!开始化妆~");
                }
            }
        }else {
   
            synchronized (Lipstick.class){
   
                System.out.println(Thread.currentThread().getName()+"拿到了口红,现在在找镜子...");
                try {
   
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
   
                    e.printStackTrace();
                }
                synchronized (Mirror.class){
   
                    System.out.println(Thread.currentThread().getName()+"又拿到了镜子!开始化妆~");
                }
            }
        }
    }
}

上述示例代码运行发生了死锁,两个线程分别持有着对方所需要的资源,在没有完成化妆任务前都不肯对已持有的资源放手,然后彼此都进入阻塞状态,永远的僵持在那里!

线程永远卡死肯定不行,所以我们要尽可能的去避免死锁,通常有三种用于避免死锁的方法:

  • 加锁顺序:当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。即上述示例中如果两个线程都是想要先用镜子,这样另一个想用镜子的人就会等待正在使用镜子的人。
  • 加锁时限:另外一种可以避免死锁的方法是在尝试获取锁的时候加一个超时时间。也就是说如果一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间后再重试。这段随机的等待时间就给了其它线程有机会获取之前被占有的锁,并且让该线程在没有获得锁的时候可以继续运行。
  • 死锁检测:这是一种更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。
     
    当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例如,线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B是否已经请求了线程A当前所持有的锁。如果线程B确实有这样的请求,那么就是发生了死锁。当然,死锁一般要比两个线程互相持有对方的锁这种情况要复杂的多。线程A等待线程B,线程B等待线程C,线程C等待线程D,线程D又在等待线程A。线程A为了检测死锁,它需要递进地检测所有被B请求的锁。从线程B所请求的锁开始,线程A找到了线程C,然后又找到了线程D,发现线程D请求的锁被线程A自己持有着。这时它就知道发生了死锁。
     
    那么当检测出死锁时,这些线程该做些什么呢?
  1. 一个可行的做法是释放所有锁,回退,并且等待一段随机的时间后重试。这个和简单的加锁超时类似,不一样的是只有死锁已经发生了才回退,而不会是因为加锁的请求超时了。虽然有回退和等待,但是如果有大量的线程竞争同一批锁,它们还是会重复地死锁(原因同超时类似,不能从根本上减轻竞争)。
  2. 一个更好的方案是给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为避免这个问题,可以在死锁发生的时候设置随机的优先级。

4.4 Lock同步锁对象

从JDK5.0开始,Java提供了更加强大的线程同步机制——通过显式的定义同步锁对象Lock来实现多线程的同步。

java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具。该锁🔒提供了对共享资源的独占访问,且每次只能有一个线程对Lock对象加锁,每个线程在访问共享资源之前应该首先获得Lock对象。

ReentrantLock类实现了Lock接口,该类拥有与synchronized相同的并发性和内存语义,但该类还可以实现显式的加锁和释放锁。所以在实现多线程安全的控制中,该类更加常用:

public class LockTest {
   
    public static void main(String[] args) {
   
        Runnable runnable = new Runnable() {
   
            private ReentrantLock lock = new ReentrantLock();
            @Override
            public void run() {
   
                try {
   
                    lock.lock();//加锁
                    for (int i = 0; i < 3; i++) {
   
                        System.out.println(Thread.currentThread().getName()+"执行了"+i+"次!");
                    }
                } finally {
   
                    try {
   
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
   
                        e.printStackTrace();
                    }finally {
   
                        lock.unlock();//解锁
                    }
                }
            }
        };
        new Thread(runnable,"A").start();
        new Thread(runnable,"B").start();
    }
}

可以看到,上述对Lock锁的使用是显式的,手动的加锁和解锁。如上述的加锁风格,一般推荐将unlock()解锁操作放到finally {}代码块中处理,避免代码块中途抛出异常,导致没有解锁,其他线程一直阻塞。

synchronized与Lock对象的区别:

  • Lock是显式锁,需要手动加锁和解锁;synchronized是隐式锁,执行到作用域之外自动解锁。
  • Lock只有代码块锁;而synchronized有代码块锁和方法锁。
  • 使用Lock锁,JVN将花费较少的资源和时间来调度线程,性能更好;并且Lock具有更好的扩展性,也可以有更多的子类。

所以一般推荐的各种锁的使用优先级为:Lock > 同步代码快 > 同步方法。

对于之前提到的一些改变线程状态的方法,对于同步锁的情况如下:

方法 锁情况 CPU情况 线程状态
sleep 不释放锁 释放cpu 阻塞状态
join 释放锁 抢占cpu 运行状态
yiled 不释放锁 释放cpu 就绪状态
wait 释放锁 释放cpu 阻塞状态

5.多线程通信

在了解多线程通信方式之前,首先需要先了解生产者消费者问题。例如我们去自助餐馆吃鸡肉。顾客就是消费者;后厨在往柜台里放鸡肉,厨师就是生产者;而鸡肉相当于就是共享资源,因为起初在后厨手上的生鸡肉变成了熟鸡肉,最后到了顾客手上是熟鸡肉变成了鸡骨头,类似厨师和顾客先后对鸡肉进行了自己的操作。整个流程上来说,厨师制作鸡肉放到柜台,顾客再从柜台拿到鸡肉。
在这里插入图片描述

5.1 多线程通信问题介绍

在多线程中,有时也会遇到类似上述的情况,即多个线程共享一个资源,但他们彼此执行的任务是有关联的,在逻辑上相互依赖,互为条件。也就是当一个线程没有得到另一个线程的消息时应等待,直到消息到达时才会被唤醒,这就是线程间的通信问题

Java中提供了几个方法来解决线程间的通信问题:

方法名 方法描述
wait() 调用wait()方法的当前线程进入等待状态,并且会让当前线程释放它所持有的锁。
wait(long timeout) 在指定的毫秒数内让当前正在执行的线程释放锁进入等待
notify() 唤醒一个处于等待状态的线程
notifyAll() 唤醒同一个对象上所有调用wait()方法的线程,优先级别高的线程优先调度

上述的所有方法只能在同步方法或者同步代码块中使用;

5.2 管程法

对于消费者生产者问题,即同样也对于多线程通信的问题,可以使用管程法来解决:

  • 生产者:负责生产数据的模块(可能是对象、方法、线程和进程);
  • 消费者:负责处理数据的模块(可能是对象、方法、线程和进程);
  • 缓冲区:消费者不能直接使用生产者的数据,他们之间有个“缓冲区”。生产者将生产好的数据放入缓冲区,消费者再从缓冲区拿数据。

在这里插入图片描述

public class PCTest {
   
    public static void main(String[] args) {
   
        Container container = new Container();
        new Productor(container).start();
        new Consumer(container).start();
    }
}
//生产者
class Productor extends Thread{
   

    //生产者需要向容器添加鸡肉
    private Container container;

    public Productor(Container container) {
   
        this.container = container;
    }

    @Override//生产:做鸡
    public void run() {
   
        for (int i = 1; i <= 100; i++) {
   //店里现在只有100只鸡可以用于做菜
            container.push(new Chicken());
        }
    }
}
//消费者
class Consumer extends Thread{
   

    //消费者需要去容器拿鸡肉
    private Container container;

    public Consumer(Container container) {
   
        this.container = container;
    }

    @Override//消费:吃鸡
    public void run() {
   
        for (int i = 1; i <= 95; i++) {
   //今天店里所有顾客总共只吃了95只鸡
            container.pop();
        }
    }
}
//产品:鸡肉
class Chicken{
    }
//缓冲区:柜台
class Container{
   
    //柜台的容量最多只能放10只鸡
    private Chicken[] chickens = new Chicken[10];
    //柜台容器计数器,当前放了的鸡的数量
    private int count = 0;

    //后厨制作鸡肉放到柜台
    public synchronized void push(Chicken chicken) {
   
        if (count==chickens.length){
   //柜台已满,生产者等待消费者吃鸡
            try {
   
                this.wait();
            } catch (InterruptedException e) {
   
                e.printStackTrace();
            }
        }
        //将鸡肉放入柜台
        chickens[count++] = chicken;
        System.out.println("【后厨】放入了一只鸡,目前柜台中鸡的总数:"+count);
        //并告知消费者有鸡肉可以取了
        this.notifyAll();
    }

    //顾客取走鸡肉,吃鸡
    public synchronized Chicken pop() {
   
        if (count==0){
   //柜台没鸡肉,消费者等待生产者制作鸡肉
            try {
   
                this.wait();
            } catch (InterruptedException e) {
   
                e.printStackTrace();
            }
        }
        //顾客吃一只鸡
        Chicken chicken = chickens[--count];
        System.out.println("《顾客》吃了一只鸡,目前柜台中鸡的总数:"+count);
        //并告诉生产者,可以继续制作鸡肉了
        this.notifyAll();
        return chicken;
    }

}

上述示例代码的运行结果大多数情况可能会让我们认为是后厨把柜台放满后顾客才来拿吃的,然后顾客把柜台的鸡肉完全拿完后后厨也才开始往柜台补货。但实际上并不是,实际上只要柜台中还有鸡肉,顾客就随时可以去拿。因为在生产者线程循环调用push方法的过程中,在循环的间隔,这时并没有调用push方法,就有很短暂的时间释放了同步锁,消费者就有机会在这个短暂的时间内去拿鸡肉。这也是我反复运行能有下图结果的原因:
在这里插入图片描述

5.3 信号灯法

另一种可以解决消费者生产者问题的方法为“信号灯法”,通过一个标志位,像红绿灯一样,满足要求的线程才能执行。

实际上,如果将上述管程法的示例改用信号灯法来实现,只需要将判断是否调用wait()方法的if语句中的判定条件改为一个boolean值即可,表示是否有货。再在边界条件时更新该标志位boolean值就可以了。

6.线程池

在程序中经常创建和销毁线程会消耗大量的系统资源,例如在高并发情况下的多线程,对程序性能影响极大。

所以为了解决该问题,就提出了线程池的思路。线程池,即提前创建好多个线程,并将这些线程放入一个容器中,处于阻塞状态;当需要使用时从容器中取,使用完再放回容器,这个容器就是线程池。线程池的应用可以避免频繁的创建和销毁线程所带来的资源开销,并实现了线程的重复利用。

JDK 5.0 起提供了线程池的相关API:ExecutorServiceExecutors

ExecutorService:真正的线程池接口,常见子类ThreadPoolExecutor

  • void execute(Runnable command):执行任务/命令,一般用来执行Runnable。
  • <T> Future<T> submit(Callable<T> task):有返回值,一般用来执行Callable。
  • void shutdown():关闭线程池。

Executors:线程池工具类,用于创建并返回不同类型的线程池:

  • newCachedThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
  • newFixedThreadPool :创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
  • newScheduledThreadPool: 创建一个定长线程池,支持定时及周期性任务执行。
  • newSingleThreadExecutor :创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
public class PoolTest {
   
    public static void main(String[] args) {
   
        Runnable runnable = ()-> System.out.println("hello~"+Thread.currentThread().getName());
        ExecutorService executorService = Executors.newFixedThreadPool(10);//创建容量为10的线程池
        executorService.execute(runnable);
        executorService.execute(runnable);
        executorService.execute(runnable);
        executorService.shutdown();
    }
}
本文来源MrKorbin,由架构君转载发布,观点不代表Java架构师必看的立场,转载请标明来源出处:https://javajgs.com/archives/25258

发表评论