多线程概述

下面就让我们了解一下什么是线程

目录

目录

进程线程(thread)的领域

进程VS线程

JVM中规定的线程

Java线程在代码中的体现

线程和方法调用栈的关系

线程中最常见的属性 

线程中可以get/set自己的优先级

 前台线程 VS 后台线程/精灵线程(daemon)/守护线程

控制另外的线程

Thread下的几个常见静态方法

线程的控制之通知线程停止

多线程的应用场景

线程之间的数据共享问题——线程通信

线程安全(Thread Safe)

锁(lock)

volatile

保护对象的内存可见性

特殊情况下可以保证原子性

保证代码重排序

单例模式

阻塞队列

​编辑 BlockingQueue介绍

生产者消费者模型

实现阻塞队列

阻塞队列实现代码

定时器

线程池

Java中提供的线程池

关于各种各样的锁

读锁(共享锁) vs  写锁(独占锁)

重入锁  vs  不可重入锁

公平锁  vs  不公平锁



进程线程(thread)的领域

    1.现在暂时介绍的都是操作系统层面上的线程

    2.进程(proce)和线程(thread)的关系

      进程 - 线程 是 1:m 的关系

      一个线程一定属于一个进程;一个进程下可以允许有多个线程。

      一个进程内至少有一个线程,通常把这个一开始就存在线程,称为主线程(main thread),主线程和其他线程之间地位是完全相等的,没有任何特殊性

    3.为什么要引入thread这一个概念

      由于进程这一个概念天生就是资源隔离的,所以进程之间进行数据通信注定是一个高成本的工作。现实中,一个任务需要多个执行流一起配合完成,是非常常见的。所以,需要一种方便数据通信的执行流概念出来,线程就承担了这一职责。

    4.什么是线程:

       线程是操作系统进行调度的基本单位

       线程变成了独立执行流的承载概念,进程退化成只是资源(不含CPU)的承载概念。

       比如:运行一个程序,没有线程之前,OS创建进程,分配资源,给定一个唯一的PC,进行运行。有了线程之后,OS创建进程,分配资源,创建线程(主线程),给定一个唯一的PC,进行运行。

        OS针对同一个进程下的线程实现“连坐”机制;一旦一个线程异常退出,OS会关闭该线程所在的整个进程

进程VS线程

1.概念的区别

2.由于进程把调度单位这一个职责让渡给线程了,所以,使得单纯进程的创建销毁适当简单

3.由于线程的创建和销毁不涉及资源分配,回收的问题,所以,通常理解,线程的创建/销毁成本要低于进程的成本

JVM中规定的线程

Java线程  VS  OS线程(原生线程)

不同的JVM有不同的实现,它们的外在表现基本一致,除了极个别的几个现象。Java线程中一个线程异常关闭,不会连坐。

我们使用的HotSpot实现(JVM)采用,使用一个OS线程来实现一个Java线程

Java中由于有JVM的存在,所以使得Java中做多进程级别的开发基本很少,Java中的线程还克服了很多OS线程的缺点,因此,在Java开发中,我们使用多线程模型来进行开发,很少使用多进程模型。

Java线程在代码中的体现

1.java线程在代码中是如何体现的

每一个线程都被抽象为java.lang.Thread类(包括其子类)的一个对象

2.如何在代码中创建线程(最基本)

    1.通过继承Thread类,并且重写run方法

       实例化该类的对象 -> Thread对象

public class MyFirstThreadClass extends Thread {
    @Override
    public void run() {
        //这个方法下写的所有代码,如果正确创建线程的话,都会运行在新的线程执行流中
        System.out.println("这是我的第一个线程");
    }
}

    2.通过实现Runnable接口,并且重写run方法。

       实例化Runnable对象

       利用Runnable对象去构建一个Thread对象。

public class MyFirstTask implements Runnable{
    @Override
    public void run() {
        System.out.println("这是我的第一个任务的第一句话");
    }
}

3.启动线程

    当手中有一个Thread对象时调用其start()方法

    注意:1.一个已经调用过start()不能再次调用start(),否则就会有异常发生

               2.千万不要调用成run()

public class MyThread extends Thread{
    @Override
    public void run() {
        //打印当前执行语句的线程的名字
        System.out.println("我是" + Thread.currentThread().getName());
    }
}
public class Main {
    public static void main(String[] args) {
        MyThread t = new MyThread();
        t.start();
    }
}

           

public class Main {
    public static void main(String[] args) {
        MyThread t = new MyThread();
       // t.start();
        t.run();
    }
}

 

 调用run方法,就和线程没关系了,完全是在主线程下在运行代码。

    怎么去理解t.start()做了什么?

         线程状态:

           新建 ——  就绪  ——  运行  ——  结束

         t.start()只做了一件事情,就是把线程的状态从新建变成了就绪,不负责分配CPU。线程把加入到线程调度器(不区分是OS还是JVM实现的)的就绪队列中,等待被调度器选中分配CPU。从子线程进入到就绪队列这一刻起,子线程和主线程在地位上就完全平等了。所有哪个线程被选中去分配CPU,就完全听天由命了,先执行子线程中的语句还是主线程中的语句理论上都是可能的,大概率是主线程中的语句先执行,因为t.start()是主线程中的语句,换言之,这条语句被执行了,说明主线程现在正在CPU上(主线程是运行状态),所以,主线程刚刚执行玩t.start()就马上发生线程调度的概率不大。

public class MyThread extends Thread{
    @Override
    public void run() {
        System.out.println("我是MyThread 类下的 run方法中的语句,会运行在子线程中");
    }
}
public class Main {
    public static void main(String[] args) {
        System.out.println("请猜猜是主线程的打印先出现还是子线程下的先出现?这个顺序是确定的吗?");
        //实际上,先打印哪个是不确定的
        //虽然大概率情况下是main中的语句先打印的
        MyThread t = new MyThread();
        t.start();
        System.out.println("我是Main 类下的 main静态方法中的语句,会运行在主线程中");
    }
}

线程和方法调用栈的关系

每个线程都有自己独立的调用栈,由于每个线程都是独立的执行流,A在调用过哪些方法,和B根本就没关系。表现为每个线程都有自己的独立的栈。

线程中最常见的属性 

1.  id  本进程(JVM进程)内部分配的唯一的 id ,只能get不能set

   public void run() {
            System.out.println(this.getId());
        }

2.name(名字)为了方便开发者看,JVM并不需要这个属性,默认情况下,如果没有给过名字,线程名字遵守Thread—……;第一个是Thread-0,Thread-1,Thread-2.可以set也可以get,可以通过setName()设置,也可以通过Thread()构造方法设置

public void run() {
            System.out.println(this.getName());
        }
  public Mythread(){
            setName("我是小红");
        }
      
public Mythread(){
            super("我是小红");//调用父类的(thread)的构造方法
        }

    id就是一个线程的身份证号(出生的时候被分配,无法被修改,不能重复);

    name就是一个线程的名字(可以重复,可以修改)

3.在 Java 代码中看到的线程状态

    1)理论中的状态

        

    2)Java代码中实际看到的状态

          只能获取不能设置,状态的变更时JVM内部控制的

new:新建

runnable:就绪 + 运行

terminated:结束

blocked + waiting + timed_waiting:阻塞 

线程中可以get/set自己的优先级

    注意:这个优先级的设置,只是给JVM一些建议,不能强制让哪个线程先被调度。

public class Main {
    public static void main(String[] args) {
        Thread main = Thread.currentThread();
        System.out.println(main.getPriority());
        main.setPriority(Thread.MAX_PRIORITY);
        System.out.println(main.getPriority());
        main.setPriority(Thread.NORM_PRIORITY);
        System.out.println(main.getPriority());
        main.setPriority(Thread.MIN_PRIORITY);
        System.out.println(main.getPriority());

    }
}

 前台线程 VS 后台线程/精灵线程(daemon)/守护线程

后台线程一般时做一些支持工作的线程

前台线程一般是做一些有交互工作的

    举个栗子:

    写了一个音乐播放器

    1.线程响应用户点击动作(前台)

    2.线程去网络上下载歌曲(后台)

public class Main {
    public static void main(String[] args) {
      Thread t = Thread.currentThread();
        System.out.println(t.isDaemon()); //返回 true 代表是后台(daemon)线程
        t.setDaemon(true);                //修改字迹是前台还是后台线程
    }
}

JVM进程什么时候才能退出:所有的前台线程退出了,JVM进程就退出了

1.必须要求所有前台线程都退出,和主线程没关系

2.和后台线程没关系,即使后台线程还在工作,也正常退出

控制另外的线程

A线程:

1.创建B线程,并启动B线程(中间A可以做点其他的工作)

2.等待B线程完成所有工作(B线程运行结束)

3.打印B线程已经退出了

B线程:

计算一个比较耗时的任务

我们用Thread.join()方法来实现

举个栗子:

1.b = new B();  b.start();

2.吃饭

3.b.join();  //这个方法会阻塞,直到B运行结束才会返回

4.这个时候B一定已经退出了

5.打印b结束了

public class Main {
    private static class B extends Thread{
        @Override
        public void run() {
            //模拟B要做很久的任务
            try{
            TimeUnit.SECONDS.sleep(5);
        }catch (InterruptedException e){
                e.printStackTrace();
            }
            println("B说:我的任务已经完成");
        }
    }
    private static void println(String msg){
        Date date = new Date();
        DateFormat format = new SimpleDateFormat("yyyy-MM-dd  HH:mm:ss");
        System.out.println(format.format(date) + ":" + msg);
    }
    public static void main(String[] args) {
        B b = new B();
        b.start();
        println("A自己先去吃饭" );
        //有join和没有join的区别
        println("A 说 :B 给我把钱送来了,结账走人");
    }
}

 这样明显是不对的,说明A吃了霸王餐

public static void main(String[] args) throws InterruptedException {
        B b = new B();
        b.start();
        println("A自己先去吃饭" );
        //有join和没有join的区别
        b.join();
        println("A 说 :B 给我把钱送来了,结账走人");
    }
}

加入join();后A 一定是等B把钱送来之后再结账走人

利用join实现线程的一个小练习:

并发对一个数组进行归并排序

四个线程分别对各自的每一段进行排序(Arrays.sort)

当t1,t2,t3,t4的工作全部完成之后

执行4路归并(省略)

前提:一个进程下的线程,共享的是同一块内存资源

public class ConcurrentSort {
    //进行排序的线程
    static class SortWorker extends Thread{
        private final long[] array;
        private final int fromIndex;
        private final int toIndex;

        //利用构造方法,将待排序的数组区间情况传入
        //对array 的[fromIndex , toIndex]进行排序
        SortWorker(long[] array, int fromIndex, int toIndex){
            this.array = array;
            this.fromIndex = fromIndex;
            this.toIndex = toIndex;
        }

        @Override
        public void run() {
            //具体的排序过程,这里使用Arrays.sort做模拟
            Arrays.sort(array,fromIndex,toIndex);
        }
    }
    //记录排序耗时
    public static void main(String[] args) throws InterruptedException {
        long[] array = ArraysHelper.generateArray(40000000);
        //分别是
        // [0,10000000]
        //[10000001,20000000)
        //[20000001,30000000)
        //[30000001,40000000)
        long s = System.currentTimeMillis();
        Thread t1 = new SortWorker(array,0,10000000);
        t1.start();
        Thread t2 = new SortWorker(array,10000001,20000000);
        t2.start();
        Thread t3 = new SortWorker(array,20000001,30000000);
        t3.start();
        Thread t4 = new SortWorker(array,30000001,40000000);
        t4.start();
        //4个线程开始分别的进行排序
        //等待4个线程全部排序完毕
        t1.join();
        t2.join();
        t3.join();
        t4.join();
        //4个线程一定全部结束了
        //TODO:进行4路归并,将4个有序数组,归并成一个有序数组
        long e = System.currentTimeMillis();
        long elapsed = e - s ;
        System.out.println(elapsed);
    }
}

 

在单线程的情况下实现

public class SingleSort {
    //记录排序耗时
    public static void main(String[] args) throws InterruptedException {
        long[] array = ArraysHelper.generateArray(40000000);
        //分别是
        // [0,10000000]
        //[10000001,20000000)
        //[20000001,30000000)
        //[30000001,40000000)
        long s = System.currentTimeMillis();
        Arrays.sort(array,0,10000000);
        Arrays.sort(array,10000001,20000000);
        Arrays.sort(array,20000001,30000000);
        Arrays.sort(array,30000001,40000000);
        //TODO:进行4路归并,将4个有序数组,归并成一个有序数组
        long e = System.currentTimeMillis();
        long elapsed = e - s ;
        System.out.println(elapsed);
    }
}

 

 我们可以明显看出单线程的耗时比多线程长

小Tips:

1.多核环境下,并发排序的耗时 < 串行排序的耗时(我们在上面看到的现象)

    单线程一定能跑在一个CPU(核)上,多线程意味着可能工作在多个核上(核亲和性)

2.单核环境下,并发排序的耗时也能小于么?

    即使在单核环境下,并发的耗时也可能较少。

    本身计算机下就有很多线程在等待分配CPU,比如,现在有100个线程。意味着公平的情况下,我们的排序主线程,只会被分配1/100的时间。当并发时,我们使用4个线程分别排序,除其他的99个之外,计算机中共有 99 + 4 = 103 个线程,我们的4个线程同属于一个进程,分给我们的进程的时间占比4/103 > 1/100。

    所以,即使单核情况下,我们一个进程中的线程越多,被分配到的时间片是越多的

    线程越多越好吗?

    当然不是

     1)创建线程也是需要耗费时间的

     2)即使理想情况下,不考虑其他耗时,极限也就是100%

     3)线程调度也需要耗时(OS从99个线程中挑一个的耗时 和 从 9999 个线程中挑一个的耗时不同)

  CPU是一个公共资源,写程序的时候也是要考虑公德心的。如果是好的OS系统,可能会避免这个问题。

3.并发排序的耗时就一定小于串行的吗?

    不一定

    串行的排序:t = t(排区间1)+ t(排区间2)+ t(排区间3)+ t(排区间4)

    并发的排序:t = 4*t(创建线程)+ t(排区间1)+ t(排区间2)+ t(排区间3)+ t(排区间4)+ 4*t(销毁)

所以我们要写多线程代码的原因之一:提升整个进程的执行速度(尤其是计算密集性的程序)

Thread下的几个常见静态方法

1.Thread.sleep(…) 让线程休眠几毫秒

   TimeUnit.SECONDS.sleep == Thread.sleep(1000)

   从线程状态的角度来说,调用sleep(…),就是让当前线程从运行态 -> 阻塞态(等待某个条件:要求时间过去……之后),当条件满足时(时间真的过去了…)线程从 阻塞态 -> 就绪。开始接着之前的指令执行。外部表现就是让线程休眠了一段时间。

2.Thread.currentThread();

    Thread引用,指向一个线程对象,执行的就是在哪个线程中调用的该方法,返回哪个对象。

3.Thread.yield()

让线程让出CPU

线程从  运行 ——> 就绪状态,随时可以继续被调度回CPU

public class Main {
    static class PrintWhoAmI extends Thread{
        private  String who;
        public PrintWhoAmI(String who){
            this.who = who;
        }
        @Override
        public void run() {
            while (true){
                System.out.println("我是" + who);
                if (who.equals("张三")){
                    Thread.yield();
                }
            }
        }
    }

    public static void main(String[] args) {
        PrintWhoAmI 张三 = new PrintWhoAmI("张三");
        PrintWhoAmI 李四 = new PrintWhoAmI("李四");
        张三.start();
        李四.start();

    }
}

张三让出了CPU所以打印出来的基本上是李四,但是张三还是会出现,只是几率较小。

yield主要用于执行一些耗时较久的计算任务时,为让防止计算机处于”卡顿“的现象,时不时的让出一些CPU资源给OS的其他进程

线程的控制之通知线程停止

举个栗子:

A叫来B干活

一些突发情况发生了,需要让B停止工作(即使分配它的任务还没有完成)

所以A需要让B停止

1.暴力停止,直接kill掉B  (目前基本上已经不采用了,原因是直接杀掉B,不知道B把工作进行的如何了,不可控)

2.更优雅地(garceful)的方式就是,和B进行协商。

    A给B主动发一个信号,代表B应该停止了(发信息)

    B在一段时间里,看到了停止信号之后,把手头的工作做到一个阶段完成,主动退出。(需要我们写代码完成)

     A主动让B停止。  b.interrupt();

     只是发了一个消息(官方:给B设置了一个停止标志)实际上并不会影响B的运行

     B如何感知到有人让它停止。

      情况1:B正在正常执行代码,可以通过一个方法来判定

                   静态方法 Thread.interrupted()检测当前线程是否被终止

                    true:有人让我们停止  false:没人让我们停止

                    B的代码类似:

                          while(true){

                                  //写代码

                                  //看一眼手机,有没有人让我们停止

                                  if(Thread.interrupted()){

                                  // 有人让我们停止

                                  break;也可以是其他方式,至于B要不要停,完全是代码控制的

                                 }

         情况2:B可能正处于休眠状态(比如 sleep,join),意味着B无法立即执行Thread.interrupted()此刻,JVM的处理方式是,以异常的形式通知B:InterruptedException,当B处于休眠状态时,捕获了nterruptedException,代表有人让我们停止,具体要不要停,什么时候停,怎么停,完全我们自己做主。

多线程的应用场景

1.计算密集性任务,为了提升整体速度,可以引入多线程

2.当一个执行流因故阻塞时,为了还能处理其他事务,可以引入多线程

举个栗子:看饭店,如果只有一个人,既要当前台又要当厨师,如果用户点了耗时比较久的菜(把我占用了),导致没法接待新顾客,有了线程(厨师)之后我只负责前台工作,做菜交给另一个人,即使做菜比较慢也不影响其他用户的体验。

public class Main {
    static  long fib(int n){
        if (n == 0 || n == 1){
            return 1;
        }
        return fib(n - 1) + fib(n - 2);
    }
//      没有线程的情况下
//    public static void main(String[] args) {
//        Scanner scanner = new Scanner(System.in);
//        while (true){
//            System.out.println("请输入一个数字");
//            int n = scanner.nextInt();
//            long r = fib(n);
//            System.out.printf("fib(%d) = %d\n",n,r);
//        }
//    }
    //引入一个线程来计算,主线程只负责读取用户输入
    static class CalcFib extends Thread{
        private final int n;
        public CalcFib(int n){
            this.n = n;
        }

        @Override
        public void run() {
            long r = fib(n);
            System.out.printf("fib(%d) = %d\n",n,r);
        }
    }

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        while (true){
            System.out.print("请输入一个数字:");
            int n = scanner.nextInt();
            //读取输入后创建一个线程去计算
            Thread t = new CalcFib(n);
            t.start();
            //主线程直接读取下一个输入
        }
    }
}

线程之间的数据共享问题——线程通信

大部分场景下,几个线程之间是需要协调配合工作(线程之间需要进行数据交换,由于我们的线程都属于同一个进程,所以,共享有OS分配过来的同样的资源,我们优先关注内存资源),一起完成一个总目标的。

JVM下的内存区域划分:pc保存区(PC),栈(虚拟机栈,本地方法栈),堆,方法区,运行时常量池

以上这些内存资源是属于进程的,理论上来讲,其实是这个进程下的所有线程。

举个例子:财产权是以家庭为单位进行分配的。家里的钱是属于家里每一个人的,家庭内部也有分配机制。 

堆,方法区,运行时常量池是整个进程(JVM)只有一份,对象和加载的类是大家共有的。

PC(保存PC的值),栈(虚拟机栈,本地方法栈)是每个线程独一份(各自有各自的)。

在我们代码中:

局部变量,保存在栈帧中,也就是保存在栈中,所以是线程私有(A创建的局部变量,只有A在用)

类对象(Class对象 关于类的对象),静态属性,保存在方法区中,所以是线程之间共享的,前提是有访问权限。

对象(对象内部的属性),保存在堆中,所以是线程之间共享的,前提是,线程持有该对象的应用

public class Main {
    static class MyThread extends Thread{
        @Override
        public void run() {
            int r = 0; //子线程的run方法的栈帧
            for (int i = 0; i < 1000 ; i++) {
                r++;
            }
            System.out.println(Thread.currentThread().getName() + ":" + r);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        int r = 0; //主线程的main方法的栈帧中
        MyThread t = new MyThread();
        t.start();
        t.join();
        System.out.println(r);
    }
}

线程安全(Thread Safe)

线程安全:

    1.代码的运行结果应该是100%符合预期(这个标准无法实操,只是为了解释)

    2.Java语境下,经常说某个类,对象是线程安全的:

        这个类,对象的代码中已经考虑了处理多线程的问题了,如果只是”简单”使用,可以不考虑线程安全的问题。

         比如:ArrayList就不是线程安全的,在ArrayList的实现中,完全没考虑过线程安全的任何问题,无法直接使用在多线程环境中(多个线程同时操作同一个ArrayList)

线程不安全现象出现的原因:

    1.多个线程之间操作同一块数据了(共享数据)——不仅仅是内存数据

    2.至少有一个线程在修改这块共享数据

在多线程的代码中,哪些情况下不需要考虑线程安全问题?

    1.几个线程之间互相没有任何数据共享的情况下,天生是线程安全的

    2.几个线程之间即使有共享数据,但都是做读操作,没有写操作时,也是天生线程安全的

会出现线程安全的几种情况:

1.原子性

先给大家演示一个栗子:

public class Main {
    //定义一个共享属性——静态属性的方式来体现
    static int r = 0;
    //定义加减的次数
    static final int COUNT = 1000000;
    //定义两个线程,分别对 r 进行 加法 + 减法 -  操作
    static class Add extends Thread{
        @Override
        public void run() {
            for (int i = 0; i < COUNT; i++) {
                r++;

            }
        }
    }
    static class Sub extends Thread{
        @Override
        public void run() {
            for (int i = 0; i < COUNT; i++) {
                r--;

            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Add add = new Add();
        add.start();
        Sub sub = new Sub();
        sub.start();
        add.join();
        sub.join();
        //理论上r被加了COUNT次也被减了COUNT次
        //所以,结果应该是0
        System.out.println(r);
    }
}

 

 我们可以看到输出的结果和我们的预期是不一样的,单看代码“没有问题”的情况下,但结果是错误的(无法100%得到预期结果)

为什么会出现这种情况呢?让我们从系统的角度来看看

前置知识:

     1.java代码(高级语言)中的一条语句,很可能对应的多条指令r++,实质就是r = r + 1。

     2.线程调度是可能发生在任意时刻的,但是不会切割指令

有了以上知识我们就可以来解释上述这种情况了,我们程序员预期r++或者r--是一个原子性的操作(全部完成or全部没完成)但实际执行起来,保证不了原子性,所以就会出错。

那为什么COUNT越大,出错的概率就越大呢?

    因为COUNT越大,线程执行需要跨时间片的概率越大,导致中间出错的概率越大。

所以原子性被破坏是线程不安全的最常见的原因!

最常见违反原子性的场景:

    1.read-write场景

      i++;

      array[size] = e; size++;

    2.check-update场景

      if(a == 10){

            a = ...;

}

2.内存可见性问题

前置知识:

    CPU中为了提升数据获取速度,一般在CPU中设置缓存(Cache)

我们先来看一个栗子:班级中有一个账本,记录着班费的情况,有一天班长出去采购了,过程中会花一些钱,班费减少了,这个事情记录在班长的脑袋里(工作内存)中,账本(主内存)上还没有减少。这时学习委员按照账本上的班费去采购其他东西,就可能出现花费超支的情况。

内存可见性:一个线程对数据的操作很可能其他线程是无法感知的,甚至某些情况下会被优化成完全看不到的结果。

3.代码重排序问题(从系统角度看)

前置知识:

我们的程序可以看作是一个状态机

程序A

状态1(a = 0,b = 1 )—语句/指令—>状态2 (a = 2 , b = 3) —语句/指令—> 状态3(a = 100 , b = 100)

这样就留给编译器很大的空间做优化

程序B

状态1(a = 0,b = 1 )—语句/指令—>状态3(a = 100 , b = 100)

如果我们站在用户的角度来看,不把时间要素考虑在内的话,程序A和程序B是等价的。

在我们写程序时,往往是经过中间很多环节优化的结果(这些优化主要有:编译器,类加载器,JVM,硬件,但是作为应用开发,我们是无从得知做了哪些优化的),并不保证最终执行的语句和我们写的语句是一模一样的。

那么我们的重排序就是指:执行的指令和书写的指令并不一致

JVM规定了一些重排序的基本原则:happend-before规则

简要的解释:JVM要求,无论怎么优化,对于单线程的视角,结果不应该有变化,但并没有规定多线程环境的情况(并不是不规定,而是不能规定,会导致多线程环境下可能出问题)

举个栗子:

 我们作为程序员如何考虑程序安全的问题:

    1.尽可能让几个线程之间不做数据共享,各干各的,就不需要考虑线程安全问题了。比如上文提到的归并排序:4个线程虽然处理的是同一个数组,但提前画好范围,各做各的,就没什么问题了。

    2.如果非要有共享操作,尽可能不去修改,而是只读操作 

       举个例子:static final int COUNT = 10;即使多个线程同时使用这个COUNT也无所谓

    3.一定会出现线程问题了,问题的原因从系统角度讲:

         1.原子性被破坏了

         2.由于内存可见性问题,导致某些线程读取到“脏数据”

         3.由于代码重排序导致的线程之间关于数据的配合出问题了

    所以,接下来需要学习一些机制,目标和JVM进行沟通,避免上述问题发生

一些常见类的线程安全问题:

Array List,LinkedList,PriorityQueue,TreeMap,TreeSet,HashSet,StringBuilder都不是线程安全的

Vector,Stack,Dictionary,StringBuffer都是线程安全的,这几个类都是Java设计失败的产品。以后大家代码中不要出现这些类。

锁(lock)

语法:

    1.修饰方法(普通,静态方法)——>同步方法

      普通方法(视为对“当前对象”加锁)

      synchronized int add(...){...}

      静态方法(视为对静态方法所在的类加锁)

      synchronized (类.class){……}

    2.同步代码块

      synchronized(引用){

      ... 

      }

所谓的锁,理论上来说,就是一段数据(一段被多个线程之间共享的数据)

为了让大家方便理解,static boolean lock = false;

锁的状态:(锁上(locked));打开(unlocked))false:unlocked      true:locked

 尝试加锁的内部操作

    1.整个尝试加锁的操作已经被JVM保证了原子性

      if(lock == false){

          //说明整个锁没有被锁上

         lock = true;//当前线程把锁锁上

         return; //正常向下执行

      }

      //如果锁已经locked(true)了

     Queue<线程> 该锁的阻塞队列 queue = ……;

     queue.add(Thread.current Thread());等待者把锁释放

     //既然我们无法获取到锁,所以就应该让出CPU

     Thread.currentThread().state = 阻塞;

     Thread.yield() //理解让出CPU

一个要知道的小现象

语句1;语句2

宏观来看应该是先执行完语句1接着执行语句2

语句1:sync(ref){语句2;…}

但事实上语句1的执行时间和语句2的执行时间相隔很久,甚至极端情况下,语句2再也不会被执行都有可能

释放锁的操作(纯理论):

这个过程由系统保证了原子性

释放锁:lock = false;

从等待锁的阻塞队列中,选择一个线程出来,恢复CPU

Thread t = queue.poll();

t.state = 就绪; //等待被分配CPU

当多个线程:都有加锁操作时并且申请的是同一把锁时,会造成,加锁 代码s(临界区) 解锁

临界区(临界区的代码不一定是同一份代码)的代码会互斥(互相排斥)着进行

public class Main {
    //这个对象用来当锁对象
    static Object lock= new Object();
    static class MyThread1 extends Thread{
        @Override
        public void run() {
                for (int i = 0; i < 10000000; i++) {
                    System.out.println("我是张三");
            }
        }
    }
    static class MyThread2 extends Thread{
        @Override
        public void run() {
            for (int i = 0; i < 10000000; i++) {
                System.out.println("我是李四");
            }
        }
    }

    public static void main(String[] args) {
        Thread t1 = new MyThread1();
        t1.start();

        Thread t2 = new MyThread2();
        t2.start();
    }
}

当我们没有加锁的时候张三和李四会交替出现

public class Main {
    //这个对象用来当锁对象
    static Object lock= new Object();
    static class MyThread1 extends Thread{
        @Override
        public void run() {
            synchronized (lock) {
                for (int i = 0; i < 10000000; i++) {
                    System.out.println("我是张三");
                }
            }
        }
    }
    static class MyThread2 extends Thread{
        @Override
        public void run() {
            synchronized (lock){
            for (int i = 0; i < 10000000; i++) {
                System.out.println("我是李四");
            }
            }
        }
    }

    public static void main(String[] args) {
        Thread t1 = new MyThread1();
        t1.start();

        Thread t2 = new MyThread2();
        t2.start();
    }
}

当我们加锁之后这两段代码一定是互斥进行的,一段代码执行完后才会执行另一段代码,但是不能保证谁先抢到。

加锁操作使得互斥有了以下的性质(synchronized 和我们一起配合,我们需要正确的使用synchronized)

    1.保证了临界区的原子性

    2.在有限程度上保证内存可见性

      加锁:加锁成功之前,清空当前线程的工作内存

      临界区代码:读某些变量时(主内存上的数据时)保证读到的是最新的,但是临界区期间的数据读写,不做保证(可能读到的数据再次被别的线程更改了,就看不到了。或者是期间有数据同步回主内存)

      解锁:保证把工作内存中的数据全部同步回主内存

    3.也可以给代码重排序增加一定的约束

      举个栗子:

      s1;s2;s3;加锁;s4;s5;s6;解锁;s7;s8;s9;

      s1;s2;s3;之间先后顺序不做保证

      s4;s5;s6;之间先后顺序不做保证

      s7;s8;s9;之间先后顺序不做保证

      s4;s5;s6;不会被重排序到加锁之前/解锁之后

      s1;s2;s3;不会被重排序到加锁之后

      s7;s8;s9;不会被重排序到解锁之前

volatile

修饰变量

JVM中线程要读变量,每次从主内存读,写入保证写回主内存。

90%的功能就是保证内存可见性的

保护对象的内存可见性

public class WithOutVolatile {
   volatile static boolean quit = false;
    static  class MyThread extends Thread {
        @Override
        public void run() {
            long r = 0;
            while (quit == false){
                r++;
        }
            System.out.println(r);
    }

    }

    public static void main(String[] args) throws InterruptedException {
        MyThread t = new MyThread();
        t.start();
        TimeUnit.SECONDS.sleep(5);
        quit = true;
    }
}

我们可以试着运行一下上述代码,当没有volatile修饰时,r是不会被打印的因为quit读取的是工作内存的值,所以quit一直是false,循环不会结束,如果我们加入volatile修饰,quit就会读取主内存的值,也就是会读取到主线程修改后的值,所以在五秒后会打印r的值。

特殊情况下可以保证原子性

JVM基本操作长度是32位

所以int ,short,byte,char,float,以及引用的赋值都是原子的(String s  =  b)

而long,double由于是64位,所以不是原子的。但是如果被volatile修饰后他们就可以成为原子的

保证代码重排序

SomeObject so = new SomeObject(…);

    1.根据类计算对象的大小;在内存中(堆)分配内存空间给该对象;memset(0x0);

    2.对象的初始化过程

      1. 构造代码块

      2.属性的初始化赋值

      3.构造方法

    3.这样我们才可以获得一个正确的对象,并把引用交给so

理论上的顺序 1 -> 2 -> 3

实际中重排序成 1 -> 3 -> 2

在多线程中会出现问题:1 -> 3 -> B:使用对象(错误),这个时候我们就可以用volatile修饰(volatile SomeObject so = new SomeObject(…);)保证顺序是理论上的数据,不会被重排序。

单例模式

通过代码保护一个类,使得类在整个进程(应用)运行过程中有且只有一个对象。

一开始就初始化(饿汉模式——等不及)

public class StarvingMode {
    //是线程安全的
    private static StarvingMode instance = new StarvingMode();
    public static StarvingMode getInstance(){
        return instance;
    }
    private StarvingMode(){
        
    }
}

等用到的时候才初始化(懒汉模式)

单线程

public class LazyModeV1 {
    private static LazyModeV1 instance = null;
    public static LazyModeV1 getInstance() {
        //当第一次调用这个方法是,说明我们应该实例化对象了
        if (instance == null) {
            instance = new LazyModeV1();//只在第一次的时候执行
        }
        return instance;
    }
    private LazyModeV1(){}
}

多线程

public class LazyModeV3 {
    private static volatile LazyModeV3 instance = null;
    private LazyModeV3() {}
    public static LazyModeV3 getInstance() {
        if (instance == null) {
            synchronized (LazyModeV3.class) {
                if (instance == null) {
                    instance = new LazyModeV3();
                }
            }
        }
        return instance;
    }
}

阻塞队列

举个栗子:

我们有两个线程一个线程是前台,另一个线程是厨师,他们之间用一个队列来传递顾客点的菜,前台从队列中放入用户点的菜,厨师从队列中取出

 厨师从队列中取数据会存在两种现象

1.取到了,直接做菜

2.没有取到(队列为空):厨师不知道该干什么了

   可以让厨师sleep一会儿,但是在sleep是可能会有人点菜

所以我们需要将这个队列改造成阻塞队列

在阻塞队列中队列为空的情况下,poll()不会返回

当我们用队列时,队列为空则会返回null

 public static void main(String[] args) throws InterruptedException {
        BlockingDeque<String> queue = new LinkedBlockingDeque<>();
        String poll = queue.poll();
        System.out.println(poll);
       

    }
}

若我们使用阻塞队列时,队列为空则不会返回 

public class Main {
    public static void main(String[] args) throws InterruptedException {
        BlockingDeque<String> queue = new LinkedBlockingDeque<>();
     
        String take = queue.take();   //只要队列中取不到元素就会一直阻塞
        System.out.println("永远到达不了");


    }
}

 BlockingQueue介绍

BlocksTimes out
put(e)offer(e,time,unit)
take()poll(time,unit)

 put(e)有两种结束方式:

1.队列中有位置了,放入元素(最常见的)

2.当有人让线程结束时,放入失败了,也会结束,以InterruptExcepition形式体现

take(),offer(e,time,unit),poll(time,unit)与put(e)一样

生产者消费者模型

一个(多个线程)只负责向队列中放入元素 ——生产者

一个(多个线程)负责从队列中取元素——消费者

实现阻塞队列

前置知识:

线程和线程之间需要互相等待通知

Object.wait()

wait 做的事情 :
使当前执行代码的线程进行等待 . ( 把线程放到等待队列中 )
释放当前的锁
满足一定条件时被唤醒 , 重新尝试获取这个锁

Object.notify()

notify 方法是唤醒等待的线程 .
方法 notify() 也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其
它线程,对其发出通知 notify ,并使它们重新获取该对象的对象锁。
如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。 ( 并没有 " 先来后到 ")
notify() 方法后,当前线程不会马上释放该对象锁,要等到执行 notify() 方法的线程将程序执行
完,也就是退出同步代码块之后才会释放对象锁。
public class Demo1 {
    static class MyThread extends Thread{
        private Object o;
        MyThread(Object o){
            this.o = o;
        }
        @Override
        public void run() {
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (o) {
                System.out.println("唤醒主线程");
                o.notify();
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Object o = new Object();
        MyThread t = new MyThread(o);
        t.start();
        synchronized (o) {
            o.wait();
            System.out.println("永远不会到达");
        }
    }
}

小Tips:
1.wait()和noyify()方法是属于Object类的,Java中的对象都带有这两个方法

2.要使用wait和notify,必须首先对”对象“进行synchronized加锁

阻塞队列实现代码

public class MyArrayBlockingQueue {
    private long[] array;
    private int frontIndex;//永远在第一个元素的位置
    private int rearIndex;//永远在队列最后一个的下一个位置
    private int size;
    public MyArrayBlockingQueue(int capacity){
        array = new long[capacity];
        frontIndex = 0;
        rearIndex = 0;
        size = 0;
    }
    public synchronized void put(long e) throws InterruptedException {
        //判断队列是否已经满了
        while (array.length == size);{
            this.wait();
        }
        //预期:队列一定不是满的
        array[rearIndex] = e;
        rearIndex ++;
        if (rearIndex == array.length){
            rereIndex = 0;
        }
        size++;
        notifyAll();
    }
    public synchronized long take() throws InterruptedException {
        while (size == 0) {
            wait();
        }
        long e = array[frontIndex];
        frontIndex++;
        if (frontIndex == array.length) {
            frontIndex = 0;

        }
        size--;
        notifyAll();
        return e;
    }
}

定时器

代码实现

public class UseTimer {
    public static void main(String[] args) {
        Timer timer = new Timer(); //闹钟
        TimerTask task = new TimerTask() {  //闹钟到时间后要做的事情
            @Override
            public void run() {
                System.out.println("闹钟响了");

            }
        };
//        timer.schedule(task,5000);
        timer.scheduleAtFixedRate(task,1000,2000);
        while (true){

        }


    }
}

Timer类——任务调度(闹钟)

抽象类TimerTask——任务

     我们要做的就是继承这个类重写run方法即可,指定要执行的任务

多长时间之后,执行一次任务

周期性的执行任务

定时器执行任务时是不会占用我们的当前执行流的

线程池

矛盾:创建/销毁线程都是有成本的

有新的任务——创建新线程(无意义成本)——执行任务——销毁线程(无意义成本)

线程池模式会提前创建好很多线程(按需创建),有新任务交给储备的线程去处理。

Java中提供的线程池

Executor(接口) -> ExecutorService(接口) -> ThreadPoolExecutor(…)(线程池版本的实现类)

ThreadPoolExecutor的构造方法

举个栗子:一个公司会有正式工(core)和临时工(tmp),正式工和临时工的名额是有上限的,正式员工是不会被解雇的,但是临时员工一旦空闲了就会被解雇。

corePoolSize:正式员工的名额上限

maximumPoolSize:正式 + 临时的名额上限

keepAliveTime + unit :临时工允许空闲时间的上限

ThreadPoolExecutor(按需创建的方式创建线程)

    1.一开始,线程池里一个工作线程都没有

    2.随着任务提交

      if(正式员工数量 <  正式工的上限){

             创建一个新的正式员工(无论是否有员工处于空闲状态)

      }

      //数量 ==上限

      暂时把任务放到队列中(任务不是立即被执行)

      如果队列的容量也满了

      雇佣临时工来处理(所有员工的总数 < maxSize)

      //员工达到上线了&&队列也满了

      执行拒绝策略

public class Demo {
        public static void main(String[] args) {
            BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1);

            ThreadFactory tf = new ThreadFactory() {
                @Override
                public Thread newThread(Runnable r) {
                    Thread t = new Thread(r, "饭店厨师");
                    return t;
                }
            };

            ExecutorService service = new ThreadPoolExecutor(
                    3, // 正式员工 3
                    9, // 临时员工 9
                    10, TimeUnit.SECONDS,
                    queue,
                    tf,
                    new ThreadPoolExecutor.AbortPolicy()
            );

            // 定义任务
            Runnable task = new Runnable() {
                @Override
                public void run() {
                    try {
                        TimeUnit.DAYS.sleep(365);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            };

            // 把任务提交给线程池对象(公司)

            Scanner s = new Scanner(System.in);
            for (int i = 1; i < 100; i++) {
                s.nextLine();
                service.execute(task);
                System.out.println(i);
            }
        }
    }

关于各种各样的锁

读锁(共享锁) vs  写锁(独占锁)

我们目前使用的锁都是独占锁(只有一个线程能持有锁)

独占锁共享锁
读 + 读互斥不互斥
读 + 写互斥互斥
写 + 写互斥互斥

当业务中,读的次数远远大于写的次数,共享锁 优于 独占锁

重入锁  vs  不可重入锁

是否允许持有锁的线程成功请求到同一把锁

举个栗子:t1 线程

                  lock1.lock();         t1成功锁上了lock1

                  …

                  lock1.lock()             注意:此时lock1处于已经锁上的状态&&请求锁的还是t1线程

                  若请求成功则为可重入锁,否则为不可重入锁

公平锁  vs  不公平锁

这里的公平就是指:是不是按照请求锁的顺序获得到锁。

juc下的ReentranLock可以通过传入fair = true | false来控制是否是公平的

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值