Java EE初阶-多线程详解


提示:以下是本篇文章正文内容,下面案例可供参考

一、认识线程

1.1概念

(1)线程是什么?
一个线程就是一个 “执行流”. 每个线程之间都可以按照顺讯执行自己的代码. 多个线程之间 “同时” 执行着多份代码.
(2)为什么要有线程?
首先, “并发编程” 成为 “刚需”.

单核 CPU 的发展遇到了瓶颈. 要想提高算力, 就需要多核 CPU. 而并发编程能更充分利用多核 CPU 资源.

有些任务场景需要 “等待 IO”, 为了让等待 IO 的时间能够去做一些其他的工作, 也需要用到并发编程

其次, 虽然多进程也能实现 并发编程, 但是线程比进程更轻量.
创建线程比创建进程更快.

销毁线程比销毁进程更快.

调度线程比调度进程更快.
ps:线程虽然比进程轻量, 但是人们还不满足, 于是又有了 “线程池”(ThreadPool) 和 “协程”(Coroutine)

(3)线程和进程的区别与联系?(面试必考)

1.进程包含线程!一个进程里面可以有一个线程,也可以有多个线程

2.进程线程都能解决并发编程问题场景,但是进程在频繁创建和销毁中,开销更高,线程开销更低(线程比进程更轻量)

3.进程是系统分配资源(内存,文件资源…)基本单位,线程是系统调度执行的基本单位(cpu)

4.进程之间是相互独立的,各自有各自的虚拟地址空间,同一个进程内部的多个线程之间,共用一个内存空间及文件资源。一个进程挂了,其他进程一般没事;但是一个线程挂了,很可能把整个进程都毁掉

1.2创建线程

在java标准库中,就提供了一个Thread类,来表示/操作线程,Thread类也可以视为是java标准库提供的API

创建好的Thread实例,和操作系统中的线程是一一对应关系
ps:操作系统,提供了一组关于线程的API,java对于这组API进一步封装了,就成了Thread类

法(1)创建子类,继承Thread类:

//最基本的创建线程方法
class MyThread extends Thread{
// 继承 Thread 来创建一个线程类.
    @Override
    public void run(){
        System.out.println("hello thread");
    }
}

public class Demo1 {
    public static void main(String[] args) {
        Thread t=new MyThread();
        // 创建 MyThread 类的实例
        t.start();
        //调用 start 方法启动线程
    }
}

运行结果如下:
在这里插入图片描述

其中
在这里插入图片描述
我们都说,线程是并发执行的,但是上述例子只要一个线程并没有体现并发的特点。我们再写一个例子:

class MyThread2 extends Thread{
    @Override
    public void run(){
        int i=0;
        while(i<100){
            System.out.println("hello thread");
            try {
                Thread.sleep(1000);//每次循环停1秒
                //这个休眠操作,就是强制的让线程进入阻塞状态,单位是ms,1s之内不会到cpu上运行
                //用sleep需要捕捉一下异常
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        Thread t=new MyThread2();
        t.start();

        int i=0;
        //在一个进程中,至少会有一个线程
        //在一个java进程中,也是至少会有一个main方法的线程(系统自动生成的)
        while(i<100){
            System.out.println("hello main");
            Thread.sleep(1000);
        }
    }
}

运行部分结果如下:
在这里插入图片描述

可以看到,hello mian和hello thread是交替打印的,也就是说我们的自己创建的t线程和自动创建的main线程,就是并发关系(此处并发=并行+并发)

ps:上面的运行结果我们也可以看到,这个打印顺序也不是严格的。也就是说,对于操作系统来说,内部对于线程之间的调度顺序,在宏观上可以认为是随机的(抢占式执行)

法(2)创建一个类,实现Runnable接口,再创建Runnable实例传给Thread实例:

class MyRunnable implements Runnable{
    @Override
    public void run(){
        System.out.println("hello");
    }
}
public class Demo3 {
    public static void main(String[] args) {
        Thread t=new Thread(new MyRunnable());
        t.start();
    }
}

运行结果如下:
在这里插入图片描述

法二简言之就是通过Runnable来描述任务的内容,进一步的再把描述好的任务交给Thread实例

法(3)使用匿名内部类
同法1类似的匿名内部类:

public class Demo4 {
    public static void main(String[] args) {
        Thread t=new Thread(){//匿名内部类用一次结束
            @Override
            public void run(){
                System.out.println("hello thread");
            }
        };
        t.start();
    }
}

运行结果如下:
在这里插入图片描述
关于法三:

Thread t=new Thread(){
...
};

我们创建了一个匿名内部类,继承自Thread类,同时重写run方法,再同时new出这个匿名内部类的实例。

同法二类似的匿名内部类:

public static void main(String[] args) {
        Thread t=new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello thread");
            }
        });
        t.start();
    }

new的Runnable,针对这个创建的匿名内部类,同时new出的Runnable实例传给了Thread的构造方法

法(4)使用Lambda表达式

// 使用 lambda 表达式创建 Runnable 子类对象
Thread t3 = new Thread(() -> System.out.println("使用匿名类创建 Thread 子类对象"));
Thread t4 = new Thread(() -> {
    System.out.println("使用匿名类创建 Thread 子类对象");
});

ps:通常认为Runnable这种写法更好一点,能够让线程和线程执行任务,更好的解耦(写代码一般希望高内聚,低耦合),Runnable单纯的描述了一个任务,至于这个任务是要通过一个进程,还是一个线程,还是一个协程来执行,Runnable本身不关心。

1.3多线程的优势-增加运行速度

多线程可以提高任务完成的效率:

比如我们现在有两个整数变量,分别要对这两个变量自增10亿次。我们使用一个线程和两个线程来对比一下执行效率。

串型执行(不使用多线程)

public static void serial(){
        long beg=System.currentTimeMillis();
        long a=0;
        for(long i=0;i<1000000000;i++){
            a++;
        }

        long b=0;
        for(long i=0;i<1000000000;i++){
            b++;
        }
        long end =System.currentTimeMillis();
        System.out.println("消耗时间"+(end-beg)+"ms");
    }

    public static void main(String[] args) {
        serial();
    }

运行时间522ms
在这里插入图片描述

两个线程

public static void concurrency() throws InterruptedException {
        long beg=System.currentTimeMillis();//开始时间
        //第一个线程
        Thread t1=new Thread(()->{
          long a=0;
            for(long i=0;i<1000000000;i++){
                a++;
            }
        });
        t1.start();

        //第二个线程
        Thread t2=new Thread(()->{
            long b=0;
            for(long i=0;i<1000000000;i++){
                b++;
            }
        });
        t2.start();

        //此处不能直接这么记录结束时间,别忘了,现在这个求时间戳的代码是在main线程中
        //main线程和t1和t2是并发关系,此处t1和t2还没执行完,这里就开始记录结束时间不准确
        //正确做法是让main线程等t1和t2跑完再记录结束时间
        t1.join();//t1.join()就是让main等等t1线程结束
        t2.join();
        long end=System.currentTimeMillis();
        System.out.println("消耗时间"+(end-beg)+"ms");
    }
    public static void main(String[] args) throws InterruptedException {
        concurrency();
    }

消耗352ms
在这里插入图片描述
可以明显看到,串行执行的时候运行时间是没有多线程少的,而提升也是很明显的

二、Thread类及常见方法

2.1Thread的常见构造方法

在这里插入图片描述

Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("这是我的名字");
Thread t4 = new Thread(new MyRunnable(), "这是我的名字");

2.2Thread的几个常见属性

在这里插入图片描述
ID 是线程的唯一标识,不同线程不会重复

名称是各种调试工具用到

状态表示线程当前所处的一个情况,下面我们会进一步说明

优先级高的线程理论上来说更容易被调度到

关于后台线程,需要记住一点:JVM会在一个进程的所有非后台线程结束后,才会结束运行。
(比如:
我们现在创建的是前台线程t1和t2:即使main方法执行完毕,进程也不能退出,得等t1和t2都执行完,整个进程才能退出

如果t1和t2是后台线程,如果main执行完毕,整个进程就直接退出,t1和t2就被强行终止了)

是否存活,即简单的理解,为 run 方法是否运行结束了
(也就是操作系统中的线程是否正在运行,Thread t对象的生命周期和内核中对应的线程,生命周期并不一致。

创建出t对象之后,在调用start前,系统中是没有对应线程的。

在run方法执行完之后,系统中的线程就销毁了,但是t这个对象可能还存在。

通过isAlive就能判定当前系统的线程运行情况。

如果调用start之后,run执行完之前,isAlive就是返回true

如果调用start之前,run执行完之后,isAlive就返回false)

线程的中断问题,下面我们进一步说明

2.3启动一个线程-start( )

start决定了我们系统中是不是真的创建出了线程,我们来看一段代码

class MyThread extends Thread{
    @Override
    public void run(){
        System.out.println("hello thread");
    }
}
//最基本的创建线程方法
public class Demo1 {
    public static void main(String[] args) {
        Thread t=new MyThread();
        t.start();
    }
}

打印hello thread
在这里插入图片描述

我们把t.start()改成t.run()

class MyThread extends Thread{
    @Override
    public void run(){
        System.out.println("hello thread");
    }
}
//最基本的创建线程方法
public class Demo1 {
    public static void main(String[] args) {
        Thread t=new MyThread();
        t.run();
    }
}

还是打印hello thread
在这里插入图片描述
那这时会有同学问了:“那run和start不是没有区别吗?”

其实区别很大:
run单纯只是一个普通方法,描述任务的内容;start则是一个特殊方法,内部会在系统中创建线程

我们再来一段代码就可以很容易看出差别了

public static void main(String[] args) {
        Thread t=new Thread(()->{
           while(true){
               System.out.println("hello thread");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        t.start();

        while(true){
            System.out.println("hello main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

打印结果就是两个线程交替打印 hello main和hello thread。
在这里插入图片描述
通过这个案例我们知道,start就是创建一个新的线程,新的线程和原有main线程并发执行

我们再来测试一下将start换为run

public static void main(String[] args) {
        Thread t=new Thread(()->{
            while(true){
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.run();

        while(true){
            System.out.println("hello main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

可以看到只会打印hello thread,而t.run()下面的代码就不再执行了
在这里插入图片描述
解释:run方法只是一个普通方法,你在main线程中调用run,其实并没有创建新的线程,这个循环仍然是在main线程中执行的

既然是在一个线程中执行的,代码就得从前往后按顺序进行,先运行第一个循环,再运行第二个循环。但是上一个循环是一个死循环(while(true))。

如果你把while(true)改一下让while可以结束,就会发现是先打印hello thread 然后再是hello main,比如:

public static void main(String[] args) {
        Thread t=new Thread(()->{
            int i=0;
            while(i<5){
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                i++;
            }
        });
        t.run();

        while(true){
            System.out.println("hello main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

这样就不会死循环在t.run()里面了
在这里插入图片描述

2.4中断一个线程

让一个线程停下来,线程停下来的关键就是让线程对应的run方法执行完(还有一个特殊的就是main这个线程,对于main来说,得是main方法执行完,线程就完了)

法(1)手动设置一个标志位(自己创建变量,boolean),来控制线程是否执行结束

public class Demo10 {
    private static boolean isQuit=false;
    public static void main(String[] args) {
        Thread t=new Thread(()->{
           while(!isQuit){
               System.out.println("hello thread");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        t.start();

        //只要把isQuit设置成true,这个while循环就结束
        //进一步的run就执行完了,再进一步就是线程执行结束
        try {
            Thread.sleep(5000);//我们要在这里休眠5s,然后设置isQuit=true
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        isQuit=true;
        System.out.println("t线程终止" );
    }
}

在这里插入图片描述
在这里插入图片描述
法(2)使用Thread中内置的一个标志位,来进行判定。使用 Thread.interrupted() 或者 Thread.currentThread().isInterrupted() 代替自定义标志位.
在这里插入图片描述
1.interrupt()

我们来看一段代码

public static void main(String[] args) {
        Thread t=new Thread(()->{
           while(!Thread.currentThread().isInterrupted()){
               System.out.println("hello Thread");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        t.start();

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

        //在主线程中,调用interrupt方法,来中断这个线程
        //t.interrupt的意思就是让t线程中断
        t.interrupt();
        //调用这个方法,可能产生两种情况:
        //1.如果t线程处于就绪状态,就是设置线程的标志位为true
        //2.如果t线程处于阻塞状态(sleep休眠了),就会触发一个InterruptException
    }

运行结果如下:在打印完5个hello Thread后,报了个警告,又继续打印了…
在这里插入图片描述

我们这段代码,大部分情况下,都是在sleep阻塞的。而在阻塞状态,你再设置标志位就没用了。

而我们希望的中断,是可以立即产生效果的。但如果线程已经是阻塞状态下,此时设置标志位,就不能起到及时唤醒的效果。

我们调用这个interrupt方法,就会让sleep触发一个异常,从而导致线程从阻塞状态被唤醒。但我们现在这个代码触发了异常后,只是进入catch语句,然后在catch中打了一个日志。而打完日志后继续运行,这显然不是我们想要的结果

我们举个简单的例子:
在这里插入图片描述

而想在触发异常后有反应,也很简单,在catch里面加一个break即可

public static void main(String[] args) {
        Thread t=new Thread(()->{
           while(!Thread.currentThread().isInterrupted()){
               System.out.println("hello Thread");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
                   break;//触发异常后立即退出循环
               }
           }
        });
        t.start();

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

        //在主线程中,调用interrupt方法,来中断这个线程
        //t.interrupt的意思就是让t线程中断
        t.interrupt();
        //调用这个方法,可能产生两种情况:
        //1.如果t线程处于就绪状态,就是设置线程的标志位为true
        //2.如果t线程处于阻塞状态(sleep休眠了),就会触发一个InterruptException
    }

运行结果如下:
在这里插入图片描述
这样就达到我们想要的效果了。当然了,如果你还想在触发异常后做一些收尾工作,你可以在 e.printStackTrace();前加一些收尾工作的代码,比如:
ps:printStackTrace是打印当前出现异常位置的代码调用栈,打印完日志后,就直接继续运行
在这里插入图片描述
运行结果如下:

在这里插入图片描述
2.interrupted()和isinterrupted()
在这里插入图片描述
前者是static的,也就是类方法,通过Thread.interrputed()进行调用

后者是实例方法,先get到Thread.currentThread().isInterrupted()来进行调用

两者清除不清除标记位不用纠结

实际工作中推荐使用
在这里插入图片描述

原因:我们一个代码中线程有很多个,随时哪个线程都可能会终止,Thread.interrupt( )这个方法判断的标志位是Thread的static成员(一个程序中只有一个标志位)

Thread.currentThread().isInterrupted()这个方法判定的标志位是Thread的普通成员,每个示例都有自己的标志位

所以,我们直接无脑使用Thread.currentThread().isInterrupted()方法即可

2.5等待一个线程-join( )

多个线程之间,调度顺序是不确定的,线程之间的执行是按照调度器来安排的,这个过程可以视为是“无序、随机的”。而这样是不太好的,我们需要能够控制线程的顺序

线程等待就是其中一种控制线程执行顺序的手段

此处的线程等待,主要是控制线程结束的先后顺序

join:调用join的时候,哪个线程调用join,哪个线程就会阻塞等待,得到对应的线程执行完毕为止(对应线程的run执行完)

public static void main(String[] args) {
        Thread t= new Thread(()->{
            for(int i=0;i<5;i++){
                System.out.println("hello Thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();

        //在主线程中就可以使用一个等待操作,来等待t线程执行结束
        try {
            t.join();
            //首先,调用这个方法的线程是main线程,是针对t这个线程对象调用的
            //此时就是让main等待t(t先结束,main后结束,一定程度上干预了两个线程的执行顺序)
            //调用join之后,main线程就会进入阻塞状态(main暂时无法在cpu上执行)
            //代码执行到join这行,就暂停了,不再继续往下执行
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

ps:join操作默认情况下是死等,就像你和别人约一个地点见面,你等了很久不对方不来,你还死等。这明显是不合理的。

所以,join还提供了另一个版本,就是可以指定等待时间(最长等多久),等不到就不等了。而这种版本的写法也很简单,就是在join()里面加一个参数

public static void main(String[] args) {
        Thread t= new Thread(()->{
            for(int i=0;i<5;i++){
                System.out.println("hello Thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();

        //在主线程中就可以使用一个等待操作,来等待t线程执行结束
        try {
            t.join(10000);
            //首先,调用这个方法的线程是main线程,是针对t这个线程对象调用的
            //此时就是让main等待t(t先结束,main后结束,一定程度上干预了两个线程的执行顺序)
            //调用join之后,main线程就会进入阻塞状态(main暂时无法在cpu上执行)
            //代码执行到join这行,就暂停了,不再继续往下执行
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

在这里插入图片描述

2.6获取当前线程引用

在这里插入图片描述
Thread.currentThread()就能够获取到当前线程的引用(Thread实例的引用),哪个线程调用这个currentThread,就获取到哪个线程的实例

public static void main(String[] args) {
        Thread t= new Thread(){
            @Override
            public void run(){
                System.out.println(Thread.currentThread().getName());//打印Thread-0(jvm默认生成的名字)
            }
        };
        t.start();

        //这个操作是在main线程中调用的,因此拿到的就是main这个线程的实例
        System.out.println(Thread.currentThread().getName());//打印main
    }

运行结果如下:
在这里插入图片描述
如果想先打印Thread-0再打印main,我们就用前面讲到的join

public static void main(String[] args) {
        Thread t= new Thread(){
            @Override
            public void run(){
                System.out.println(Thread.currentThread().getName());//打印Thread-0(jvm默认生成的名字)
            }
        };
        t.start();
        try {
            t.join();//调用这个方法的是main线程,也就是main线程会等t线程
            //是针对t这个线程对象调用的
            //此时就是让main等待t
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //这个操作是在main线程中调用的,因此拿到的就是main这个线程的实例
        System.out.println(Thread.currentThread().getName());//打印main
    }

运行结果如下
在这里插入图片描述
一些注意点:
在这里插入图片描述

2.7休眠当前线程

线程休眠也就是我们的sleep操作,但是sleep到底在干啥呢?

我们回顾之前知识,进程:通过PCB描述,通过双向链表组织

而上面这种说法,是只针对只有一个线程的进程。但是如果一个进程有多个线程,那么每个线程都有一个PCB,也就是说,一个进程对应的就是一组PCB。

PCB上有一个字段tgroupld,这个id其实就相当于进程的id,同一个进程的若干线程的tgroupld是相同的

ps:可能有同学问:PCB不是process control block吗?这不是进程控制块吗?和线程有啥关系呢?——其实Linux内核,不区分进程和线程,进程线程是程序员写的应用程序代码弄的词,实际上Linux内核只人PCB,在内核中Linux把线程称为:轻量级进程

比如我们现在有若干的PCB,这些PCB在一个链表上保存着
在这里插入图片描述
当前这个链表上的PCB都有各自的状态(就绪/堵塞)
比如我们现在这个就是就绪队列
在这里插入图片描述
如果某些线程调用了sleep方法,这个PCB就会进入阻塞队列,比如我们pid为100的PCB

在这里插入图片描述
那么我们操作系统调度线程的时候,只是从就绪队列中挑选合适的PCB到CPU上运行,而阻塞队列里的PCB只能等着。而当睡眠时间到,系统就会把这个PCB从阻塞队列中挪回就绪队列。

三、线程的状态

我们之前讲过,进程是有状态的:就绪/阻塞,而这里的状态就决定了系统按怎样的态度来调度这个进程(但这种是只针对于一个进程只有一个线程的情况)

大多数情况下,是一个进程中包含多个线程,这时的状态其实是绑定在线程上:比如2.7讲过在Linux里面,PCB与线程对应,一个进程对应一组PCB,每个线程都有自己的PCB。而状态就是PCB上的一个属性,所以每个线程都会有自己独立的状态,因此系统在调用线程时,就会根据它的状态来决定:调度/暂缓调度

上面说的“就绪”和“阻塞”都是针对系统层面的线程状态(PCB),在java中Thread类又对于线程的状态进一步细化:

3.1观察线程的所有状态

线程的状态是一个枚举类型 Thread.State

public class ThreadState {
    public static void main(String[] args) {
        for (Thread.State state : Thread.State.values()) {
            System.out.println(state);
       }
   }
}

在这里插入图片描述
1.New:

public static void main(String[] args) {
        Thread t=new Thread(()->{

        });
        System.out.println(t.getState());
        //在t调用前获取其状态,打印New
        t.start();
    }

NEW:Thread对象已经创建好了,但是还没有调用start

2.TERMINATED:

public static void main(String[] args) {
        Thread t=new Thread(()->{

        });
        t.start();
        try {
            Thread.sleep(1000);
            //我们不确定是main先执行还是t先执行,所以不能保证拿到的一定是TERMINATED
            //于是我们这里休眠1s
            //由于我们的t线程啥也没干,所以我们这里休眠1s,t肯定已经执行完了
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(t.getState());//打印TERMINATED
    }

2.TERMINATED:操作系统中的线程已经执行完毕,销毁了,但是Thread对象还在

3.RUNNABLE:

public static void main(String[] args) {
        Thread t=new Thread(()->{
            while(true){
                //这里啥也不能有——如果有了,不一定就是RUNNABLE状态了
                //一直持续不断的执行这里的循环,系统想调度它随时都可以
            }
        });
        t.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(t.getState());//打印RUNNABLE
    }

ps:如果代码中没有进行sleep,也没有进行其他的可能导致阻塞的操作,代码大概率是处在Runnable状态的
RUNNABLE:就是我们常说的就绪状态,处于这种状态的线程,就是在就绪队列中,随时可以被调度到CPU上。

4.TIMED_WAITING:

public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread(()->{
            while(true){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
        Thread.sleep(1000);
        System.out.println(t.getState());//打印TIMED_WAITING
    }

TIMED_WAITING:代码中调用了sleep,jion(时间)就会进入这种状态,表示该线程在一定时间内是阻塞状态

5.BLOCKED:

BLOCKED:表示当前线程等待锁(synchronized后面介绍),导致阻塞

6.WAITING:

WAITING:当前线程在等待唤醒(wait后面介绍),导致了阻塞

ps:4、5、6都是阻塞状态之一

3.2线程状态和状态转移的意义

状态为什么要这么细分?——开发过程中经常遇到“卡死”的情况,这种卡死原因可能是阻塞,也有可能是bug。划分清楚线程状态,有益于程序员更好的区分卡死的原因。

3.3观察线程的状态和转移

在这里插入图片描述
(图片来自比特就业课)

四、多线程带来的风险(重点)

线程安全问题是我们整个线程中最重要,也是最复杂的问题。如果面试官问多线程,一定会问线程安全

操作系统调度线程是随机的(抢占式执行),而因为这样的随机性,就可能导致程序的执行出现一些bug,

如果因为这样的调度随机性引入了bug,就认为代码是线程不安全的。如果是因为这样的调度随机性,也没有带来bug,就认为代码是线程安全的(没有bug)。

4.1观察线程不安全及其原因

我们现在来看一个线程不安全的典型案例:
使用两个线程,对同一个整形变量进行自增操作。
每个线程自增5w次,看最终的结果

class Counter{
    //这个变量就是两个线程要去自增的变量
    public int count;

    public void increase(){
        count++;
    }
}
public class Demo15 {
    private static Counter counter=new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for(int i=0;i<50000;i++){
                counter.increase();
            }
        });

        Thread t2=new Thread(()->{
            for(int i=0;i<50000;i++){
                counter.increase();
            }
        });

        t1.start();

        t2.start();

        //在main中打印一下,两个线程自增完成后,得到的count值
        //这里要main要等待一下,否则t1,t2还没执行完main就打印了
        t1.join();
        t2.join();
        //ps:这里两个join谁在前,谁在后没有关系
        System.out.println(counter.count);
    }
}

运行结构如下:
在这里插入图片描述
那这个问题就来了,我每个线程自增5w次,2个线程应该自增10w次啊,怎么会是55820这个数呢?而且,如果你多次运行,会发现每次输出的数都不一样。这明显是出bug了啊。

要想知道出现这种情况的原因,我们必须要知道count++到底干了啥,站在CPU角度看,count++实际上是3个CPU指令:

比如我们现在有一个内存和CPU
在这里插入图片描述

1.load:把内存中count值,加载到cpu寄存器中
在这里插入图片描述

2.add:把寄存器中的值+1
在这里插入图片描述

3.save:把寄存器中的值写回到count中
在这里插入图片描述
而由于我们之前说的抢占式执行,这就导致两个线程同时执行这三个指令的时候,顺序上就充满了随机性。

比如我们现在举个例子:
在这里插入图片描述
如上图,假设t1,load了一个0到cpu中,然后t1再add,cpu上数变成了1,然后再save,内存上的数变成了1。

然后t2load内存上的1到cpu上,t2再add,cpu上的数变成了2,然后再save,内存上的数变成了2

而这只是抢占式执行过程中可能出现的一种情况,我们再来看其他的情况:
在这里插入图片描述
如上图,假设t1,load了一个0到cpu中,然后t1再add,cpu上数变成了1。

这个时候t2抢占了,load一个0,寄存器上值又从1变成了0,t2再add变成了1。

就相当于+了两次,但只有一次生效
而我们想要的是他们两个分别加1次使得count变成2,所以这明显不是我们想要的情况。。。

而由于抢占式执行,类似还有很多不合理的情况可能会发生
这些不合理的情况就是产生bug的根源,也就导致了线程不安全问题

ps:虽然每次输出的数都不一样,但是我们可以给一个确定的范围保证输出的数在这个范围内:

笔者这里的代码是每个线程自增5w次,那么我输出的范围一定是5w-10w,为什么?

线程都是并发的,这5w对并发相加过程中,有可能是串行的(比如前面说的第一种情况:最终结果+2),也有可能是交错的(比如前面说的第二种情况:最终结果+1)

极端情况下(取极限):
所有操作都是串行:结果就是10w(可能性极小)
所有操作都是交错的:结果就是5w(可能性极小)

而所有情况都包含在这两个极限之间,所以说笔者输出的范围一定是5w-10w

4.2线程安全的概念

想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

4.3产生线程不安全的原因

什么样的代码会产生线程不安全问题呢?——不是所有的多线程代码都要加锁,否则多线程的并发能力形同虚设

1.线程是抢占式执行,线程间的调度随机性(根本原因)

2.多个线程对同一个变量进行修改操作(如果多个线程对不同变量修改或多个线程对同一变量进行读操作都没有问题)

3.针对变量的操作不是原子的(要么一次全执行完,要么不执行)

ps:关于原因2、3的解决一些办法:
2——可以通过调整代码结构,使不同线程操作不同变量
3——加锁操作,把多个操作打包成一个原子的操作

4.内存可见性,也会影响到线程安全
举个例子:针对一个变量,一个线程进行读操作(循环很多次),另一个线程进行修改操作(合适的时候执行一次)

在这里插入图片描述
t1这个线程,在循环读这个变量时,按照之前的介绍,读取内存操作相对于读寄存器是非常低效的操作(慢3-4个数量级)

因此t1频繁的读取这里内存的值就非常的低效,而且你t2执行不了修改操作,t1就永远读的是内存里的同一个count值。
而这就导致你的电脑可能会执行一个意外操作:t1不再读内存里的值,而直接去读寄存器里的值。但是这样的话,一旦t2进行了修改,内存里的值变了,t1就不能及时发现了

ps:可能有同学会问:“它怎么敢的啊,我没让它这么做,它能这样?”其实是有可能的,我们的java,C++,python等编译器在进行代码优化的时候,确实有可能产生这样的效果

这就是内存可见性,具体举例可见本文六、volatile

5.指令重排序,也会影响到线程安全
指令重排序,也是编译器优化中的一种操作,举个例子:

下面是一个超市,箭头表示入口和出口,然后超市里卖一些货物:比如鸡蛋、芹菜。。。
在这里插入图片描述
然后我女朋友给我列了个清单:黄瓜,鸡蛋,西红柿,芹菜。让我去买,那么我现在开始买东西:

如果严格按照列表上的顺序买,那我的路线应该是:
在这里插入图片描述

可以发现,我们绕了很多路,那我们怎么买合适呢?
我们调整一下路线顺序:鸡蛋——芹菜——黄瓜——西红柿
在这里插入图片描述
这样的话,路线就顺很多了

放在我们代码中也是一样的:如果可以调整一下代码的执行顺序,并且保证执行效果不变,效率是会提高的

对于我们写的很多代码,彼此的顺序,谁在前,谁在后没有什么区别的,编译器就会保证代码逻辑不变的前提下智能的调整这里代码的前后顺序,从而提高程序的运行效率

如果代码是单线程的程序,编译器的判断一般是很准确的。但是如果是多线程的,编译器也可能会产生误判

解决方法:
1.synchronized:不光能保证原子性,同时还能保证内存的可见性,同时还能禁止指令重排序

4.4解决之前的线程不安全问题

如何解决线程安全不安全的问题呢?加锁!!!
我们举一个生动的例子:
现在大家在排队取钱
在这里插入图片描述
那你取钱的时候如果有人不怀好意,靠近你,并偷看了你银行卡,大事不妙啊!,但是如果我们在取钱的时候加个锁,也就是取钱的时候把门关上,取款机那个房间只有你一个人,这样别人就进不来了。通过这把锁,就可以避免我们上述描述的一些乱序排序执行的情况

具体流程:
1.自增前加锁:lock
1.自增后解锁:unlock

我们这时来看看交错的情况加锁之后是什么效果,比如:
在这里插入图片描述

现在线程t1加上lock,成功获取到锁,然后t1进行后续操作load。不同的是,这时t2想加锁lock,但是由于t1已结加锁了,所以t2的lock就会发生阻塞。

而它会一直阻塞到t1把锁打开,也就是t1的unlock结束,如下图:
在这里插入图片描述
通过上面的流程,我们乱序的并发,变成了一个串行(类似单线程了),这时我们的运算结果就正确了。

五、synchronized 关键字-监视器锁monitor lock

java中加锁的方式有很多种,最常用的是synchronized
我们仍用之前两个线程各自自增5k,但是我们对之前的Counter类里面的increase方法上锁(在前面加synchronized)

class Counter{
    //这个变量就是两个线程要去自增的变量
    public int count;

    synchronized public void increase(){
        count++;
    }
}
public class Demo15 {
    private static Counter counter=new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for(int i=0;i<50000;i++){
                counter.increase();
            }
        });

        Thread t2=new Thread(()->{
            for(int i=0;i<50000;i++){
                counter.increase();
            }
        });

        t1.start();

        t2.start();

        //在main中打印一下,两个线程自增完成后,得到的count值
        //这里要main要等待一下,否则t1,t2还没执行完main就打印了
        t1.join();
        t2.join();
        //ps:这里两个join谁在前,谁在后没有关系
        System.out.println(counter.count);
    }
}

运行结果如下:
在这里插入图片描述
在这里插入图片描述

5.1synchronized的特性

1.互斥:
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.

进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁
在这里插入图片描述
(图片来自比特就业课)
2.刷新内存:
synchronized 的工作过程:

  1. 获得互斥锁
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁

所以 synchronized 也能保证内存可见性. 具体代码参见后面 volatile 部分
3.可重入:

什么叫可重入呢?
直观来讲:同一个线程针对同一个锁,连续加锁两次,如果出现了“死锁”,就是不可重入,如果不会死锁,就是可重入的

ps:死锁——举例说明:
在这里插入图片描述
那如果我们放在代码中谈,连续锁两次会发生什么?
在这里插入图片描述
(图片来自比特就业课)

这种代码在实际开发中,稍不留神就写出来了,如果代码真的死锁了,bug就来了。而实现JVM的大佬们也注意到这点,就把synchronize实现成了可重入锁,对于可重入锁来说,上述连续加锁操作,不会导致死锁。

可重入锁内部,会记录当前的锁是被哪个线程占用的,同时也会记录一个“加锁次数”。线程a针对锁第一次加锁,可以成功,锁内部记录了当前占用的是a,同时加锁次数为1。后续a再对锁加锁的时候,就不是真的加锁,而是单纯的把加锁次数变为2,以此类推。。。

后续再解锁的时候,先把计数进行-1,当锁计数减到0时,就真的解锁了

ps1:可重入锁的意义就是降低了程序员的负担(降低使用成本,提高开发效率)。代价就是程序中有更高的开销(维护锁的线程,降低了运行效率)

ps2:死锁的四个必要条件:
1.互斥使用:一个锁被线程占用后,其他线程占用不了(锁的本质,保证原子性)
2.不可抢占:一个锁被线程占用后,其他线程不能把这个锁抢走
3.请求和保持:一个线程占据了多把锁后,除非显式地释放锁,否则这些锁始终被该线程持有
4.环路等待:比如A等B,B等C,C等A

5.2synchronized使用示例

synchronized 本质上要修改指定对象的 “对象头”. 从使用角度来看, synchronized 也势必要搭配一个具体的对象来使用.
1.直接修饰普通方法(实例方法): 锁的 SynchronizedDemo 对象:

public class SynchronizedDemo {
    public synchronized void methond() {
   }
}

2. 修饰静态方法(类方法): 锁的 SynchronizedDemo 类的对象:

public class SynchronizedDemo {
    public synchronized static void method() {
   }
}

ps:下面两种写法是一样的效果
在这里插入图片描述

3. 修饰代码块: 明确指定锁哪个对象:
(1)锁当前对象

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            
       }
   }
}

(2)锁类对象

public class SynchronizedDemo {
    public void method() {
        synchronized (SynchronizedDemo.class) {
       }
   }
}

5.3Java 标准库中的线程安全类

Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施:
ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder

但是还有一些是线程安全的. 使用了一些锁机制来控制:
Vector (不推荐使用)
HashTable (不推荐使用)
ConcurrentHashMap
StringBuffer

还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的:
String

六、volatile关键字

volatile和原子性无关,但是能保证内存的可见性,它可以禁止编译器做出4.3描述的优化操作

我们来看一段代码:

public class Demo16 {
    private static int isQuit=0;

    public static void main(String[] args) {
        Thread t=new Thread(()->{
            while(isQuit==0){

            }
            System.out.println("循环结束,t线程退出");
        });
        t.start();

        Scanner scanner=new Scanner(System.in);
        System.out.println("请输入一个isQuit的值");
        isQuit=scanner.nextInt();
        System.out.println("main线程执行完毕.");
    }
}

在这里插入图片描述
我们的main线程执行完了,但是t线程仍然是没有退出的,也就是我们4.3所说的内存可见性问题,你t一直在读isQuit,但是读内存操作是非常低效的,而t又一直要读,main线程想修改又不知道什么时候,t就有可能直接不读内存了,转而去读寄存器上的值,详情原因请见本文4.3 这就导致main线程已经把isQuit值修改了,但是t线程不知道,所以t线程一直无法退出。

而解决这个问题的方法有两个:
1.使用synchronized:
synchronized不仅可以保证指令的原子性,同时也能保证内存的可见性,被synchronized包裹的代码,编译器就无法做出代码优化,相当于手动禁用了编译器的代码优化

2.使用volatile:
volatile和原子性无关,但是它也能够保证内存可见性,也就是禁止编译器代码优化

比如我们现在对isQuit加上volatile关键字再来看看运行结果:

public class Demo16 {
    private static volatile int isQuit=0;

    public static void main(String[] args) {
        Thread t=new Thread(()->{
            while(isQuit==0){

            }
            System.out.println("循环结束,t线程退出");
        });
        t.start();

        Scanner scanner=new Scanner(System.in);
        System.out.println("请输入一个isQuit的值");
        isQuit=scanner.nextInt();
        System.out.println("main线程执行完毕.");
    }
}

加上volatile之后,我们输入一个isQuit值后,main线程执行完毕,t线程也会立即结束
在这里插入图片描述

七、wait和notify

等待和通知

wait和notify也是用来处理线程调度随机性问题的。

我们多线程调度存在太多变数了,而我们不喜欢这种随机性,我们希望彼此之间有一个固定的顺序。

举个例子:我们打篮球——两个队友进行配合“传球”、“上篮”。得有一个人传球给你,你才能上篮啊。如果都没人给你传球,你在那上篮,这不搞笑吗?

实际开发中也是这样,我们程序需要一定的流程

wait和notify都是Object对象的方法。
调用wait方法的线程就会陷入阻塞,阻塞到其他线程通过notify来通知

7.1wait( )方法

public static void main(String[] args) throws InterruptedException {
        Object object=new Object();
        System.out.println("wait前");
        object.wait();
        System.out.println("wait后");
    }

我们运行上述代码结果如下:
在这里插入图片描述
在这里插入图片描述
而这个异常翻译过来就是“非法的锁状态异常”

为什么会出现这种情况?
wait内部会做三件事:
1.释放锁
2.等待其他线程通知
3.收到通知后重新获取锁,并继续往下执行

因此要想使用wait/notify,就得搭配synchronized,我们加上synchronized看看刚才代码的执行效果

public static void main(String[] args) throws InterruptedException {
        Object object=new Object();
        synchronized (object){
            System.out.println("wait前");
            object.wait();
            System.out.println("wait后");
        }
    }

在这里插入图片描述
代码走到wait前这里,就发生阻塞了

7.2notify( )方法

private static Object locker=new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
           //进行wait
            synchronized (locker){
                System.out.println("wait 之前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("wait之后");
            }
        });
        t1.start();

        Thread.sleep(3000);

        Thread t2=new Thread(()->{
            //进行notify
            synchronized (locker){
                System.out.println("notify之前");
                locker.notify();
                System.out.println("notify之后");
            }
        });
        t2.start();
    }

在这里插入图片描述

该代码t1走到wait之前的时候,触发了wait,然后会阻塞。

Thread.sleep(3000);等待3秒钟

线程t2跑起来就进行了notify通知,notify通知完了,wait才会真正进行返回,然后打印wait之后

7.3notifyAll( )方法

wait和notify都是针对同一个对象来操作的,例如现在有一个对象o

现在有10个线程,都调用了o.wait,此时10个线程都是阻塞状态

如果调用了o.notify,就会随机把10个其中一个给唤醒

如果用notifyAll,就会把10个线程全部唤醒

ps:wait唤醒之后,会重新尝试获取到锁(这个过程会发生竞争,所以一般还是notify用的更多)

7.4wait和sleep对比

其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻
塞一段时间,
唯一的相同点就是都可以让线程放弃执行一段时间.
当然为了面试的目的,我们还是总结下:

  1. wait 需要搭配 synchronized 使用. sleep 不需要.
  2. wait 是 Object 的方法 sleep 是 Thread 的静态方法
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

劲夫学编程

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值