Java多线程知识点,看这一篇就够了!(超详细)

目录

一、认识线程(Thread)

1、概念

 2、第一个多线程程序

 (1)观察线程

 3、创建线程

二、Thread 类及常见方法

1、Thread 的常见构造方法

2、Thread 的几个常见属性

 3、启动一个线程:start

4、终止一个线程

(1)程序员手动设置标志位

(2)直接 Thread 类

 5、等待一个线程

6、获取当前线程的引用

7、休眠当前线程

三、线程的状态

1、观察线程的所有状态 

NEW

RUNNABLE

BLOCKED 

WAITING 

TIMED_WAITING

TERMINATED

2、线程状态和状态转移的意义

四、多线程带来的风险——线程安全(最重要的) 

1、线程安全问题的演示 

2、线程安全问题的原因

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

4、synchronized 关键字

5、内存可见性引起的问题

6、wait 和 notify

 7、wait 和 sleep 之间的对比

五、多线程案例

1、单例模式

(1)饿汉模式

(2)懒汉模式

(3)解决懒汉模式的线程安全问题

(4)懒汉模式的内存可见性问题

2、阻塞队列

3、定时器

4、线程池

六、常见的锁策略

1、乐观锁 VS 悲观锁

2、重量级锁 VS 轻量级锁

3、自旋锁 VS 挂起等待锁

4、读写锁 VS 互斥锁

5、公平锁 VS 非公平锁

6、可重入锁 VS 不可重入锁

七、死锁

八、synchronized原理

1、基本特点 

2、加锁工作过程 

 九、CAS

1、概念 

2、CAS 的应用

(1)实现原子类

(2)实现自旋锁

 3、CAS 的 ABA 问题

十、JUC(java.util.concurrent) 的常见类

1、Collable interface

2、ReentrantLock

3、原子类

4、信号量 Semaphore

5、CountDownLatch

十一、集合类

(1)多环境使用 ArrayList

(2)多线程环境使用队列

(3)多线程环境使用哈希表

(a)Hashtable

(b)ConcurretnhashMap


一、认识线程(Thread

1、概念

多进程已经很好的实现了并发编程的效果

但是还有一个明显的缺点:进程太重了

1、消耗的资源更多

2、速度更慢

如果进程创建销毁不频繁还好,一旦需要大规模的创建和销毁进程,开销就比较大了

开销大体现在给进程分配资源的过程

于是聪明的程序员想了一个办法:能不能创建进程的时候,只分配一个简单的pcb,而不去分配后续的这些内存硬盘资源

于是就有了轻量级进程,也就是线程(Thread)

线程就是只创建了一个pcb,但是没有分配后续的 内存、硬盘.....等资源

线程搞出来,是为了执行一些任务,但是执行任务又需要消耗这些硬件资源

于是,我们创建的还是进程,但是创建进程的时候,把资源都分配好,在后续创建的线程,让线程在进程的内部(进程和线程的关系,可以认为是 进程 包含了 线程

后续进程中的新的线程,直接复用前面进程这里创建好的资源

其实,一个进程,至少要包含一个线程

所以最初创建出来的这个,可以视为是一个只包含一个线程的进程(此时创建的过程需要分配资源,此时第一个线程的创建开销可能是比较大的)

但是后续再在这个进程里面创建线程,就可以省略分配资源的过程,资源是已经有了的

一个进程中的多个线程,共同复用了这个进程中的各种资源(内存、硬盘),但是这些线程各自独立的在cpu上进行调度

因此,线程就可以既能够完成 “并发编程” 的效果,又可以以比较轻量的方式来进行

线程,同样也是通过PCB来描述的

windows上,描述进程和线程是不同的结构体,但是在Linux上,Linux开发者复用了pcb这个结构体,来描述线程

此时,一个pcb对应到一个线程,多个pcb对应一个进程

pcb中的内存指针、文件描述符表,同一个进程的多个pcb中,这两个字段的内容都是一样的,但是上下文状态,优先级,记账信息....支持调度的属性,这些pcb中每个人都不一样

于是就有了这两句话:

进程 是操作系统进行分配的基本单位

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

多线程编程 和 多进程编程 相比,确实有优势:更轻量,创建销毁更快

但是也有缺点:不像进程那么稳定

在Java中,进行并发编程,还是要考虑多线程

不管是 “多线程” 还是 “多进程” 本质上都是 “并发编程” 的实现模型,实际上,还存在很多其它的“并发编程” 的实现模型 

谈谈进程和线程的区别和联系

1、进程包含线程,都是为了实现 “并发编程” 的方式.线程比进程更轻量

2、进程是系统分配资源的基本单位,而线程是系统调度执行的基本单位. 创建进程 的时候把分配资源的工作给做了,后续创建线程,直接共用之前的资源即可

3、进程有独立的地址空间、彼此之间不会相互影响到,进程的独立性,让系统的稳定性得到提升,多个线程共用一份地址空间,一个线程一旦抛出异常,就可能会导致整个进程异常结束,也就意味着多个线程之间容易相互影响 

4、还有一些其它的要点,但是上面三条最核心的观点是很重要的

线程是更轻量,但是也不是没有创建成本的,如果非常频繁的创建线程 / 销毁线程,开销就不可忽视了

这个时候聪明的程序员又想到了俩个办法;

1、"轻量级线程",也就是协程 / 纤程

这个东西 Java标准库目前还没有内置,但是有一些第三方库实现了协程

2、“线程池”

池(poll)其实是计算机中非常经典的思想方法:把一些要释放的资源,不要着急释放,而是先放到一个池子里,以备后续使用;申请资源的时候,也是先提前把要申请的资源申请好,也放到一个“池子里”,后续申请的时候也比较方便


 2、第一个多线程程序

线程本身是操作系统提供的概念,操作系统也提供了一些API来供程序员使用(Linux,pthread)

Java中就把操作系统的API又进行了封装,提供了thread类

先创建一个类,让它继承 Thread,然后再重写一下 run 方法,这里的run 就相当于线程的入口 方法,线程具体跑起来之后,要做什么事,都是通过这个 run 来描述的

不仅仅是要创建出类,还需要调用它,让它执行起来 ,此处的 start 就是在创建线程(这个操作就会在底层调用 操作系统 提供的API ,同时就会在操作系统内核里创建出对应的pcb结构,并且加入到对应链表中)

此时,新创建出来的线程就会参与到cpu的调度中,这个线程接下来要执行的工作,就是刚刚上面刚刚重写的run 方法 

java.lang 这个包比较特殊,这里的类,不需要手动 import 直接默认就能使用

class Mythread extends Thread{
    @Override
    public void run() {
        System.out.println("hello world!");
    }
}


public class Demo1 {
    public static void main(String[] args) {
        Mythread mythread = new Mythread();
        mythread.start();
    }
}

也就是说每当点击运行程序的时候,就会先创建出一个Java进程,这个进程就包含了至少一个线程,这个线程也叫做主线程,也就是负责执行 main 方法的线程

如果将上面的代码改成 mythread.run();,我们会发现也能正确执行,但是它和 start 是有区别的

run 只是上面的入口方法(普通的方法),并没有调用系统API,也没有创建出真正的线程来

现在我们调整代码成这个样子:

class Mythread extends Thread{
    @Override
    public void run() {
        while(true){
            System.out.println("hello thread!");
        }
    }
}


public class Demo1 {
    public static void main(String[] args) {
        Mythread mythread = new Mythread();
        mythread.start();
        //mythread.run();

        while(true){
            System.out.println("hello main!");
        }
    }
}

此时,在 main 方法中有一个 while 循环,在线程的 run 中也有一个 while 循环,这两个循环都是 while(ture) 死循环。

使用 start 的方式执行,此时这两个循环都在执行,并且交替打印结果,这是因为两个线程分别执行自己的循环,这两个线程都能够参与到 cpu 的调度中,这两个线程并发式的执行

主线程和新线程式并发执行的关系,全看操作系统怎么调度

如果写成 run 方式,也就意味着并没有创建出新的线程,仍然是在原来的主线程内部,只有将run 中的 while 执行完,才能回到调用位置,往后执行,由于 run 中的while是一个死循环,所以主线程中的while没有机会执行

每个线程,都是一个独立的执行流

每个线程都可以执行一段代码

每个线程之间是并发执行的关系 

有时候,我们会在 run 中加入一个 sleep 

Thread.sleep(1000);

上述这些差异,我们都在Java中做了一个统一,Thread.sleep就相当于是把上述的系统函数进行了封装,如果是在Windows版本的jvm上运行,底层就是调用Windows的Sleep,如果是在Linux版本的jvm里面运行,底层就是调用Linux的sleep

sleep 是 Thread 类的静态方法

对于这个异常,目前我们直接选择使用try-catch 的方法解决 ,然后我们再次运行,就会变成一个有节奏的运行了


注意:此处并不是非常严格的交替,也存在顺序反过来的情况

这是因为 这两个线程在进行 sleep 之后,就会进入阻塞状态每当时间到,系统就会唤醒这两个线程,并且恢复对这两个线程的调度,当两个线程都被唤醒之后,调度的先后顺序可以视为是“随机” 的

系统在进行多个线程调度的时候,并没有一个非常明确的顺序,而是按照这种 “随机” 的方式来进行调度,这样的 “随机” 调度的过程,称为“抢占式执行” 

平时谈到的随机,其实暗含了“概率均等”这样的设定,但是这里的随机至少看起来随机,实际上并不一定保证概况均等


 (1)观察线程

当我们创建出线程之后,也是可以通过一些方式直接观察到的 

1、直接使用 idea 的调试器

运行的时候选择按照调试的方法执行

 

2、jconsole

官方在jdk中提供的调试工具

我们可以按照之前jdk下载的路径在bin 目录下找到 jconsole

然后先将程序运行,再打开jconsole,上面列出了系统上正在运行的所有的Java项目(jconsole也是一个用Java写的程序)

 然后选中线程在进行连接,然后选择线程标记页

此时,左下角就显示了所有的线程这里列出了当前进程中所有的线程,不仅仅是主线程和自己创建的线程,还有一些别的线程,其它剩下的线程,都是 JVM 里面自带的,负责完成一些其它方面的工作

点击线程之后,就可以查看到线程的详细信息

堆栈跟踪:线程的调用栈、显示方法之间的调用关系

当程序卡死了,我们就可以查看一下这里每个线程的调用栈,就可以大概知道是在哪个代码中出现的卡死的情况


 3、创建线程

 Java中,通过Thread 类创建线程的方式,还有很多种写法

1、创建一个类,继承 Thread ,重写 run 方法

2、创建一个类,实现 Runnable,重写 run 方法

实现 指实现 interface 

Java中,interface 通常会使用一个形容词词性的词来描述

package thread;

import java.util.TreeMap;

class MyRunnable implements Runnable{
    @Override
    public void run() {
        while(true){
            System.out.println("hello thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

//使用 Runnable 的方式创建线程
public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        MyRunnable runnable = new MyRunnable();
        Thread t = new Thread(runnable);
        t.start();
        while(true){
            System.out.println("hello main");
            Thread.sleep(1000);
        }
    }
}

区别:

Thread 这里,是直接把要完成的工作放到了 Thread 的 run 方法中

Runnable 这里,则是分开了,把要完成的工作放到了 Runnable 中,再让 Runnable 和 Thread 配合

把线程要执行的任务,和线程本身,进一步的解耦合了

3、继承Thread,重写 run ,基于匿名内部类

1、先创建了一个子类,这个子类继承自Thread,但是这个子类没有名字(匿名),另一方面,这个类的创建是在Demo3里面

2、在子类中,又重写了run方法

3、创建了该子类的实例,并且使用 t 这个引用来指向

//通过匿名内部类的方式,创建线程

public class Demo3 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(){
            @Override
            public void run() {
                while(true){
                    System.out.println("hello Thead!");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        };

        t.start();
        while(true){
            System.out.println("hello main!");
            Thread.sleep(1000);
        }

    }
}

4、实现Runnable,重写run,基于匿名内部类

1、创建了一个Runnable子类(类,实现Runnable)

2、重写了run方法

3、把子类创建出实例,把实例传给Thread的构造方法 

)对应的是Thread 构造方法的结束,new Runnable 整个一段,都是在构造出一个Thread的参数

public class Demo4 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                while(true){
                    System.out.println("hello Thread");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        });

        t.start();
        while(true){
            System.out.println("hello main!");
            Thread.sleep(1000);
        }
    }
}

5、使用lambda 表达式,表示run的内容(推荐)

 lambda表达式本质上就是一个“匿名函数”

这样的匿名函数,主要就可以作为 回调函数 来使用

回调函数,不需要我们程序员自己主动调用,而是在合适的时机,自动的被调用

经常会用到 “回调函数” 的场景:

1、服务器开发:服务器受到一个请求,触发一个对应的回调函数

2、图形界面开发:用户的某个操作,触发一个对应的回调

此处的回调函数,就是在线程创建成功之后,才真正执行

类似于 lambda 这样的写法,本质上并没有新增的语言特性,而是把以往能实现的功能,换了一种更简洁的方式来编写 

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

        t.start();

    }
}

线程的创建,不只有上面这五种方法,例如还有 基于Callable 和 基于线程池 等方法,这几个都是目前比较常见的写法


二、Thread 类及常见方法

Thread 就是Java 线程的代言人,也就是说,系统中的一个线程,就对应到Java中的一个 Thread 对象,围绕线程的各种操作,都是通过 Thread 来展开的 

1、Thread 的常见构造方法

Java给线程的命名:Thread-0 这种的,可读性比较差,如果一个程序中,有几十个线程,功能各自不同,那么我们就可以在创建线程的时候给线程进行命名 

public class Demo6 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            while(true){
                System.out.println("hello Thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        },"myThread");

        t.start();

    }
}

 我们在这里给线程取名为 "Mythread",此时当代码运行起来,就可以在jconsole中查找到线程

我们会发现此时,线程里面没有 main 了,这是因为 main 执行完了 

线程是通过 start 创建的,一个线程入口方法执行完毕(对于主线程,是 main ;对于其它线程,run / lambda)


2、Thread 的几个常见属性

ID:getId()

线程的身份标识(在 JVM 这里给线程设定的标识)

一个人可以有很多个名字,一个线程也可以有好几个身份标识

JVM 有一个身份标识,pthread 库(系统给程序员提供的操作系统的api),也有一个身份标识,内核里,针对线程的pcb还有身份标识,这几个身份标识之间是相互独立的

状态、优先级:

Java中的线程状态和操作系统中,有一定的差异

设置 / 获取 优先级的作用不是很大,因为线程的调度,主要还是系统内核来负责的

是否后台线程:

后台线程 / 守护线程:后台线程不影响结束

前台线程: 会影响到进程结束,如果前台线程没执行完,进程是不会结束的

如果一个进程中所有的前台线程都执行完了,此时即使后台线程没有执行完,也会随着进程一起退出

创建的线程,默认是一个前台线程,可以通过 setDeamon()的方式,设置后台进程

//后台线程 和 前台线程

public class Demo7 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            while(true){
                System.out.println("hello Thread!");

            }
    });

        //设置成后台线程
        t.setDaemon(true);
        t.start();

    }
}

是否存活

表示的是,Thread 对象对应的线程(系统内核中)是否存活

换句话说,Thread 对象的生命周期并不是和系统中的线程完全一致的!!! 

一般,都是Thread 对象先创建好,手动调用 start ,内核才真正创建出线程

消亡的时候,可能是 Thread 对象先结束了生命周期(没有引用这个对象)

也有可能是 Thread 对象还在,内核中的线程先把 run 执行完了,就结束了


 3、启动一个线程:start

在系统中,真正创建出一个线程:

1、创建出PCB

2、把PCB加入到对应链表中

这个是由系统内核来完成的 

什么叫操作系统内核?

操作系统 = 内核 + 配套的程序

内核就包含了一个系统最核心的功能:

1、对下,管理好各种硬件设备

2、对上,给各种程序员提供稳定的运行环境

最关键的就是:调用系统的api完成线程的创建工作

start 方法本身的执行是一瞬间就完成的,调用 start 完毕之后,代码就会立即继续执行 start 后续的逻辑


4、终止一个线程

一个线程,run  方法执行完毕,就算终止了

此处的终止,就是想办法,让 run 能够尽快的执行完毕

正常情况下,不会出现 run 没执行完,线程突然就没了的情况,如果一个线程做事情做到一半,啊突然就没了,有时候是会带来一些问题的

(1)程序员手动设置标志位

通过这个手动设置的标志位

//线程终止

public class Demo8 {
    public static boolean isQuit = false;

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

        t.start();

        //主线程这里执行一些其它逻辑之后,让 t 线程结束
        Thread.sleep(3000);

        //这个代码就是在修改前面设置的标志位
        isQuit = true;
        System.out.println("把线程终止");
    }
}

通过上述代码,确实是让 t 线程结束了,sleep(3000)实际上打印了 4 次,也很正常

主要是 sleep 操作也存在误差,一旦真是的sleep 时间比 3000 更多了此时 t 线程就可能打印出第 4 个日志,所以打印 3 次 和 4 次都是有可能的

为啥 isQuit 写作成员变量就能被访问到,写作局部变量就会报错?

 lambda 表达式,可以捕获到外面的变量

既然 lambda 表达式,执行时机是更靠后的,这就导致后续真正执行 lambda 的时候,局部变量 isQuit 是否可能已经被销毁了呢?

也就是说,main 线程先一步结束,此时 isQuit 被销毁了,当真正执行lambda的时候,isQuit已经被销毁了

这种情况是客观存在的,如果让 lambda 去访问一个已经别销毁了的变量,很明显是不合适的

lambda 引入了 “变量捕获” 这样的机制

lambda 内部看起来是在直接访问外部的变量,其实本质上是把外部的变量复制了一份,到 lambda 里面(这样就可以解决刚才的生命周期的问题)

但是,变量捕获有一个限制

变量捕获要求捕获的变量得是 final 

当我们把  isQuit = true;  删去的时候,并不会发生报错,但是会给出一个提示:

意思是,虽然没有使用 final 修饰,但是并没有在代码中修改变量,此时这个变量也可以视作是 final 

如果这个变量想要进行修改,这个时候就不能进行变量捕获了

那么为什么要进行这个设定呢?

Java是通过 复制 的方式来实现 “变量捕获”,如果外面的代码要对这个变量进行修改,就会出现一个情况:外面的变量变了,里面的没变(容易出现歧义)

相比之下,其他语言(JS)采取了更激进的设计:也有变量捕获,不是通过复制的方式实现,而是直接改变外部变量的生命周期,从而保证lambda在执行的时候肯定能访问到外部的变量(此时 js 中的变量捕获就没有 final 的限制)

如果写作成员变量,就不是触发 “变量捕获” 的机制了,而是 “内部类访问外部类成员” ,本身就是ok 的,也就不用关心是否是 final 的问题了


(2)直接 Thread 类

Thread 类给我们提供好了现成的标志位,不用我们手动设置标志位

//线程终止,使用 Thread 自带的标志位

public class Demo9 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            //Thread.currentThread() 其实就是 t
            //但是 lambda 表达式是在构造 t 之前就定义好了的,编译器看到的 lambda 里的 t 就会认为这是一个还没初始化的对象
            while(!Thread.currentThread().isInterrupted()){
                System.out.println("hello Thread!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        t.start();
        Thread.sleep(3000);
        
        //把上述的标志位设置成 true
        t.interrupt();
    }
}

当代码运行起来后,我们会发现,程序并没有按照我们的想法去执行

根据提示,我们可以将其理解成: t 线程正在 sleep ,然后被 interrupt 给唤醒了

如果是手动设置标志位,此时是没法唤醒 sleep 的 

一个线程,可能是在正常运行,也可能是在 sleep,如果这个线程在 sleep 过程中,是否应该把它唤醒呢?

还是得唤醒的

因此,当线程正在sleep过程中,其它线程调用 interrupt 方法,就会强制使sleep抛出一个异常,sleep就会立即被唤醒了(假设你设定 sleep(1000),虽然才过去了 10 ms,没到1000 ms,也会被立即唤醒)

但是 sleep 在被唤醒的同时,会自动清除前面设置的标志位!!!

此时,如果想继续让线程结束,直接就在 catch 中加一个 break 即可

//线程终止,使用 Thread 自带的标志位

public class Demo9 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            //Thread.currentThread() 其实就是 t
            //但是 lambda 表达式是在构造 t 之前就定义好了的,编译器看到的 lambda 里的 t 就会认为这是一个还没初始化的对象
            while(!Thread.currentThread().isInterrupted()){
                System.out.println("hello Thread!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    //throw new RuntimeException(e);
                    break;
                }
            }
        });

        t.start();
        Thread.sleep(3000);

        //把上述的标志位设置成 true
        t.interrupt();
    }
}

当 sleep 被唤醒之后,程序员接下来可以有以下几种操作方式:

1、立即停止循环,立即结束线程

2、继续做点别的事情,过一会再结束线程(catch中执行别的逻辑,执行完了再break)

3、忽略终止的请求,继续循环(不写break)

 

前者是静态方法,判定标志位会在判定的同时被清除标志位了

后者是成员方法,因此更推荐使用后者 


 5、等待一个线程

多个线程之间是并发执行的,具体的执行过程都是由操作系统负责调度的

操作系统的调度过程是“随机” 的

因此,我们无法确定线程执行的先后顺序

等待线程,就是一种规划线程结束顺序的手段

等待结束,就是等待 run 方法执行完毕

阻塞:让代码暂时不继续执行了(该线程暂时不去cpu上参与调度)

通过 sleep 也能让线程阻塞,但是这个阻塞时有时间限制的

join 的阻塞,就是“死等”

join能否被 interrupt 唤醒呢?

是可以的,sleep,join,wait....产生阻塞之后,都是可能被interrupt方法唤醒的,这几个方法都会在被唤醒之后自动清除标志位(和sleep类似)

public class Demo10 {
    public static void main(String[] args) {
        Thread b = new Thread(() -> {
            for(int i = 0;i < 5;i ++){
                System.out.println("hello B!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("b 结束了");
        });
        
        Thread a = new Thread(() -> {
            for(int i = 0;i < 3;i ++){
                System.out.println("hello A!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            try {
                //如果 b 此时还没执行完毕, b.join 就会产生阻塞情况
                b.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("A 结束了");
        });

        b.start();
        a.start();

    }
}

6、获取当前线程的引用

这个方法我们以及非常熟悉了

public class ThreadDemo {
    public static void main(String[] args) {
        Thread thread = Thread.currentThread();
        System.out.println(thread.getName());
   }
}

7、休眠当前线程

也是我们比较熟悉一组方法,有一点要记得,因为线程的调度是不可控的,所以,这个方法只能保证实际休眠时间是大于等于参数设置的休眠时间的

参数是 ms 作为单位 

但是 sleep 本身存在一定的误差

设置 sleep(1000) ,不一定是精确的就休眠1000ms!!(线程的调度,也是需要时间的)

sleep(1000) 的意思就是说该线程在 1000 ms 之后,就恢复成“就绪状态”,此时可以随时去cpu上执行了,但是不一定是马上就去指定

因为 sleep 的特性,也诞生了一个特殊的技巧:sleep(0)

让当前线程放弃 cpu ,装备下一轮的调度(一般做后台开发不太涉及到)

yield 方法的效果就和 sleep(0) 的效果一样 

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        System.out.println(System.currentTimeMillis());
        Thread.sleep(3 * 1000);
        System.out.println(System.currentTimeMillis());
   }
}

三、线程的状态

1、观察线程的所有状态 

 Java中一共提供了下面这六种状态:

NEW

Thread 对象创建好了,但是还没有调用Thread方法

可以通过代码来验证这一点: 

//线程的状态

public class Demo11 {
    public static void main(String[] args) {
        Thread t = new Thread(() ->{
        while(true){
           // System.out.println("hello Thread!");
        }
        });

        System.out.println(t.getState());

        t.start();

    }
}

RUNNABLE

 就绪状态,可以理解成两种情况:

(1)线程正在 cpu 上运行

(2)线程在这里排队,随时都可以去cpu上执行

//线程的状态

public class Demo11 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() ->{
            while(true){

            }
        });

        System.out.println(t.getState());

        t.start();

        //t.join();
        Thread.sleep(1000);
        System.out.println(t.getState());
    }
}

BLOCKED 

因为 产生了阻塞

WAITING 

因为调用了 wait 产生了阻塞

TIMED_WAITING

因为 sleep 产生了阻塞 

如果使用 join 带超时时间的版本,也会产生 TIMED_WAITING

//线程的状态

public class Demo11 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() ->{
            while(true){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        System.out.println(t.getState());

        t.start();

        //t.join();
        Thread.sleep(1000);
        System.out.println(t.getState());
    }
}

TERMINATED

工作完成了的状态 

//线程的状态

public class Demo11 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() ->{
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        System.out.println(t.getState());

        t.start();

        t.join();
        System.out.println(t.getState());
    }
}

2、线程状态和状态转移的意义

 如果用更加简单易懂的方式来理解状态之间的转移,那么可以用下图来表示:


四、多线程带来的风险——线程安全(最重要的) 

1、线程安全问题的演示 

我们先来看这段代码及其运行结果:

//线程安全问题演示

class Counter{
    public  int count = 0;

    public void increase(){
        count++;
    }
}

public class Demo12 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        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();

        t1.join();
        t2.join();

        System.out.println(counter.count);
    }
}

按理来说,此时的运行结果应该为 100000,可是却出现了如下的结果:

按理来说,两个线程针对一个变量,进行循环自增,各自自增 5w 次,预期结果应该是 10w,但是实际上并不是,并且当我们多次运行程序,会发现每次的运行结果都不一样 

在多线程下,我们发现由于多线程执行,导致的bug,统称为 “线程安全问题” ,如果某个代码,在单线程下执行没有问题,多个线程下执行也没问题,则称为 “线程安全”,反之称为 “线程不安全”

那么线程是否安全,则看代码中是否存在 bug,尤其是因为多线程引起的 bug 

count++;

这个操作,本质上是三个步骤:

1、把内存中的数据,加载到CPU 的寄存器中(load).

2、把寄存器中的数据进行 +1(add)

3、把寄存器中的数据写回到内存中(sava).

如果上述的操作,在两个线程,或者更多个线程并发执行的操作下,就可能会出现问题!

为了好的表示这个 count 的执行过程,我们画一个时间轴来更好的理解

由于此处,这两个线程的调度顺序是不确定的,所以这两组 操作 的相对的顺序也是会存在差异

                                                                                                                       

虽然自增两次,但是由于两个线程是并发执行,就可能在一定的执行顺序下,就导致运算的中间结果被覆盖了

在这 5w 次的循环过程中,有多少次,这两线程的 ++ 是串行的,有多少次会出现覆盖结果,四不确定的,因为线程的调度是 “随机”  的

此处,这里的结果就会出现问题i,而且得到的这个错误值,一定是小于 10w 的

很多代码都会涉及到线程安全问题,不止是 count++


2、线程安全问题的原因

1、【根本原因】多个线程之间的调度顺序是 “随机” 的,操作系统使用 “抢占式” 执行的策略来调度线程

和单线程不同的是,多线程下,代码的执行顺序产生了更多的变化,以往只需要考虑代码在一个固定的顺序下执行,执行正确即可。现在则要考虑多线程下,N种执行 顺序下,代码执行的结果都得正确

当前主流的操作系统,都是这样的抢占式执行

2、多个线程同时修改同一个变量,容易产生线程安全问题

一个线程修改一个变量,多个线程读取同一个变量,多个线程修改多个变量,这三种情况都没事

这个条件其实说的是 代码结构 的问题,我们可以通过调整代码结构来规避这种情况的发生,这也是解决线程安全问题,最主要的切入手段

3、进行的修改,不是 “原子的” 

如果修改操作,能够按照原子的方式来完成,此时也不会有线程安全问题

4、内存可见性,引起的线程安全问题

5、指令重排序,引起的线程安全问题 


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

 加锁,就相当于把一组操作,给打包成一个 “原子” 的操作

事务的那个原子操作,主要是靠回滚,而此处的原子,则是通过锁,进行 “互斥”:我这个线程进行工作的时候,其它线程无法进行工作

代码中的锁,就是让多个线程,同一时刻,只有一个线程能使用这个变量

面对之前的代码,就是在进行 count ++ 的时候,进行加锁操作

Java 中,引入了一个 synchronized 关键字 

当我们给方法加锁,就意味着,进入方法,就会加锁(lock).  出了方法,就会解锁(unlock). 

当 t1 加锁之后, t2 也尝试加锁,此时 t2 就会发生阻塞等待,这个阻塞会一直持续到 t1 把锁释放之后, t2 才能够加锁成功 

此处 t2 的阻塞等待,就把 t2 的针对 count 的 ++ 操作推迟到了后面完成,知道 t1 完成了 count++,t2 才能够真正进行 count++ ,把 “穿插执行” 变成了 “串行执行”

那么此时,我们来看看加锁之后的效果:

通过加锁操作,就把并发执行变成了串行执行了,那么此时多线程还有存在的意义吗?

我们当前的这个线程,并不只是做了 count++ 这一件事,increase 这个方法因为加锁,变成了串行的,但是上面的 for 循环,并没有加锁,因为不涉及到线程安全问题,for 循环中操作的变量 i 是栈上的一个局部变量。两个线程,是由两个独立的栈空间,也就是完全不同的变量。

也就是说,两个线程中的 i 不是同一个变量,两个线程修改两个不同的变量,是没有线程安全问题的,也就不需要加锁了

因此,这两个线程,有一部分代码是串行执行的,有一部分是并发执行的,仍然要比纯粹的串行执行的效率要高


4、synchronized 关键字

这个关键字 是 Java 给我们提供的加锁方式(关键字)

往往是搭配代码块来完成的:

1、进了代码块就加锁

2、出了代码块就解锁

synchronized 进行加锁解锁,其实是以 “对象” 为维度进行展开的

加锁,目的是为了互斥使用资源(互斥的修改变量)

使用 synchronized 的时候,其实是指定了某个具体的对象进行加锁,当 synchronized 直接修饰方法,此时就相当于对 this 加锁(修饰方法相当于上述代码的简化)

如果两个线程针对同一个对象进行加锁,就会出现 锁竞争 / 锁冲突(一个线程能加锁成功,另一个线程阻塞等待)

如果两个线程针对不同的对象加锁,不会产生锁竞争,也就不存在阻塞等待等一系列操作了

像刚才这个代码,如果要是两个线程针对不同的对象加锁,此时,就不会有阻塞等待,也就不会让两个线程按照串行的方式进行 count++ ,也就仍然会存在线程安全问题

例如下面这段代码: 

//线程安全问题演示

class Counter{
    public  int count = 0;
    public Object locker = new Object();

     public void increase(){
        synchronized (this){
            count++;
        }
    }

    public void increase2(){
         synchronized (locker){
             count++;
         }
    }
}

public class Demo12 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        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.increase2();
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(counter.count);
    }
}

但是如果把第一个 increase 的加锁对象 改成 locker ,此时仍然是同一个对象,就可以出现阻塞等待,也就自然会解决线程安全问题

具体针对哪个对象加锁,不重要;最重要的是两个线程是否针对同一个对象加锁

如果接下来代码里,一个线程加锁了,一个线程没有加锁,此时是否会存在线程安全问题呢? 

仍然存在问题!!!

一个巴掌拍不响,单方面加锁等于没加锁 ,必须多个线程对同一个对象加锁才有意义!

你针对哪个对象加锁都可以,()里不一定要写 this,写啥不重要,重要的是多个线程的加锁操作是不是针对同一个对象,synchronized 里写的对象可以是一个,但是实际上操作的成员可以是另一个

如果用 synchronized 修饰静态方法,就相当于用 synchronized 针对类对象进行加锁

一个类的完整信息,最初是在 .java 文件中的,进一步会被编译成 .class,jvm 加载 .class 就会解析里面的内容,构造出一个内存中的对象,也就是类的类对象

在 synchronized 中,()里写类对象还是普通对象,都没有任何区别, synchronized 不关心对象是啥,只关心两个线程是否针对同一个对象加锁


5、内存可见性引起的问题

现在有这么一段代码: 

 t1 始终进行 while 循环, t2 则是要让用户通过控制台输入一个整数,作为 isQuit 的值

当用户输入的仍然是0,t1 线程继续执行,当用户输入的是非0,线程就应该循环结束

//内存可见性

import java.util.Scanner;

public class Demo13 {

    public static int isQuit = 0;

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

            }
            System.out.println("t1 执行结束");
        });

        Thread t2 = new Thread(() ->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入 isQuit 的值");
            isQuit = scanner.nextInt();
        });

        t1.start();
        t2.start();
    }
}

 但是当我们运行程序后,发现了问题:当我们输入 非0 的值的时候,已经修改了 isQuit 的值,但是 t1 线程仍然在执行

程序在 编译运行的时候,Java编译器和jvm ,可能会对代码进行一些“优化”

编译器优化,本质上是靠代码智能地对你写地代码进行分析判断、进行调整,这个调整大部分情况下都是 ok 的,都能保证逻辑不变,但是如果遇到多线程了,此时的优化就可能出现差错,使程序中原有的逻辑发生改变了。

这个条件判断,本质上使两个指令:

1、load(读内存),读内存操作速度非常慢

2、jcmp(比较,并跳转),这个比较是寄存器操作,速度极快

此时,编译器 / JVM 就发现,这个逻辑中,代码要反复的、快速的读取同一个内存的值,并且这个内存的值,每次读出来还是一样的,编译器就做出一个大胆的决策:直接把 load 操作优化掉了,后续都不再进行 load ,直接拿寄存器中的数据进行比较了 

但是,它没想到程序员在另一个线程中,修改了 isQuit 的值,此时,编译器没法准确判断出 t2 线程到底会不会执行,啥时候执行,因此就出现了误判。

虽然这里把内存中 isQuit 的值给改了,但是另一个线程中,并没有重复读取 isQuit 的值,因此 t1 线程就无法感知到 t2 的修改,也就出现了上述的问题

volatile 关键字就是用来弥补上述缺陷的

把 volatile 用来修饰一个变量之后,编译器就明白了,这个变量是 “易变” 的,就不能按照上述方式,把读操作优化到寄存器中.(编译器会禁止上述优化),于是就能保证 t1 在循环过程中,始终都能读取内存中的数据

我们可以看到,在源代码中加入 volatile 后,就能 t1 线程就能正常终止运行

volatile 本质上是保证变量的 内存可见性 (禁止该变量的读操作被优化到读寄存器中) 

编译器优化,其实是一个玄学的问题,什么时候进行优化,什么时候不优化,我们有些摸不到规律

如果上述代码稍微改动一下,可能就不会触发上述优化,例如:

当我们加了 sleep 之后, sleep 就大幅度的影响到了 while 循环的速度,速度慢了,编译器也就不打算再继续优化了,此时即使不加 volatile ,也就能够及时感知到内存的变化了

补充:工作内存 (work memory ),不是我们说的 冯诺依曼体系结构 中的内存,而是 cpu 的寄存器 + cpu缓存 ,统称为 “工作内存”

内存可见性问题:

1、编译器优化

2、内存模型

3、多线程

volatile 保证的是内存可见性,不是原子性!!!


6、wait 和 notify

wait 和 notify 也是多线程中的重要工具

多线程调度是 “随机" 的,很多时候,我们希望多个线程能够按照我们规定的顺序来执行,完成线程之间的配合工作

wait 和 notify 就是一个用来协调线程顺序的重要工具

这两个方法都是由 Object 提供的方法,也就是说,随便找个对象,都可以使用 wait 和 notify

现在,当我们尝试使用 wait 方法的时候,编译器出现了提示:

意思是:当 wait 引起线程阻塞之后,可以使用 interrupt 方法,把线程给唤醒,打断当前线程的阻塞状态

然后我们将异常抛出,再次执行代码,发现发生了异常:

意思是:非法的锁状态异常

锁的状态,无非就是两个:加锁和解锁

那么为什么会发生报错呢?

wait 再执行的时候,会做三件事:

1、解锁,object.wait 就会尝试对 object 对象进行解锁

2、阻塞等待

3、当被其它线程唤醒之后,就会尝试重新加锁,加锁成功,wait 执行完毕,继续往下执行其他逻辑

wait 要解锁的前提,就是要先加上锁,但是我们此时甚至还没加锁 

核心思路:先加锁,再在 synchronized 里面进行wait

当我们对这个代码进行加锁之后再运行程序,就会发现此时就成功进入到了阻塞状态 ,这里的 wait 就会一直阻塞到其它线程进行 notify 了

wait 和 notify 主要的用途,就是用来安排线程之间的执行顺序,其中最典型的一个场景,就是能够有效避免 “线程饥饿 / 饥饿”

wait 和 notify 的演示:

//wait 和 notify
public class Demo15 {
    //使用这个锁对象来负责加锁,wait,notify
    private static Object locker = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() ->{
            while(true){
                synchronized (locker){
                    System.out.println("t1 wait 开始");
                    try {
                        locker.wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.println("t1 wait 结束");
                }
            }
        });
        t1.start();

        Thread t2 = new Thread(()->{
            while(true){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker){
                    System.out.println("t2 notify 开始");
                    locker.notify();
                    System.out.println("t2 notify 结束");
                }
            }
        });
        t2.start();

    }
}

执行效果:

几个注意事项:

1、要想让 notify 能够顺利唤醒 wait ,就需要确保 wait 和 notify 都是使用同一个对象调用的

2、wait 和 notify 都需要放到 synchronized 之内的,虽然 notify 不涉及 "解锁操作",但是Java叶强制要求 notify 要放到 synchronized 中

3、如果进行 notify 的时候,另一个线程并没有处于 wait 状态,此时,notify 相当于 “空打一炮”,不会有任何的副作用

线程可能有多i个,比如,可以有 N 个线程进行 wait ,一个线程负责 notify ,此时 notify操作只会唤醒一个线程,具体是唤醒了哪个线程,是随机的

notifyAll 则可以唤醒全部处于 waiting 中的线程

如果像唤醒某个指定的线程,就可以让不同的线程,使用不同的对象来进行 wait,像唤醒谁,就可以使用对应的对象来 notify


 7、wait 和 sleep 之间的对比

sleep 是有一个明确的时间的,到达时间自然就会被唤醒,也能提前唤醒,使用 interrupt 就行了

wait 默认是一个死等状态,一直等到其它线程 notify,wait 也能被 interrupt 提前唤醒

notify 可以理解成顺利成章的唤醒,唤醒之后改线程还需要继续工作,后续还会进入到 wait 状态

interrupt 则是告知线程要结束了,接下来线程就要进入到收尾工作了

wait 也有一个带有超时时间的版本(与 join 类似)

因此,协调多个线程之间的执行顺序,当然还优先考虑使用 wait 和 notify,而不是 sleep


五、多线程案例

1、单例模式

单例模式是一种设计模式

设计模式,就是程序员的棋谱,介绍了很多典型的场景,以及典型场景的处理方式

单例模式对应的场景:有的时候,希望有的对象,在整个程序中只有一个实例(对象),也就是说只能 new 一次

依赖人来保证,是不靠谱的。此处需要靠编译器来进行一个更加严格的检查,想办法让代码中,只能 new 一次对象,如果尝试 new 了多次,就会直接报错

在Java中,有很多种写法可以达到这样的效果,这里我们主要就介绍两种

1、饿汉模式(迫切)程序启动,类加载之后,立即创建出实例

2、懒汉模式(延时)在第一次使用实例的时候,再创建,否则能不创建就不创建

举个例子:

编译器打开一个文件,假设有一个超大的文件,有的编译器会一下把所有的内容都加载到内存中(饿汉)有的编译器就只加载一部分内容,其它部分,用户翻页的时候翻到了,用的时候再加载(懒汉)

(1)饿汉模式

带有static ,代表类属性,由于每个类的类对象是单例的,类对象的属性(static)也就是单例的了

当我们对代码做出一个限制:禁止别人去 new 这个实例,也就是把构造方法改成 private

这个代码的执行实际,是在 Singleton 类被 jvm 加载的时候,Singleton 类会在 jvm 第一次使用的时候被加载,也不一定是在程序启动的时候                                                         

 此时,这个代码就编译报错了

这时能够使用的实例,就有且只有一个。通过这样的写法,就能够有效的实现单例模式了

那么,将构造方法设置成 private ,外面就一定调用不了了吗?

使用反射,确实可以在当前单例模式中,创建出多个实例。但是反射本身是属于 “非常规” 的编程手段,正常开发的时候,应该慎重使用,滥用反射会带来极大的风险:让代码变得比较抽象,难以维护

//单例模式(饿汉模式)
class Singleton{
    private static Singleton instance = new Singleton();

    public static Singleton getInstance(){
        return  instance;
    }

    //做出一个限制:禁止别人去 new 这个实例
    private Singleton(){

    }
}

public class Demo16 {
    public static void main(String[] args) {
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();

        //Singleton s3 = new Singleton();  会报错

        System.out.println(s1 == s2);
        //System.out.println(s1 == s3);
    }
}

(2)懒汉模式

class Singletonlazy{
    private static Singletonlazy instance = null;

    public static Singletonlazy getInstance(){
        if (instance == null){
            instance = new Singletonlazy();
        }
        return instance;
    }

    private Singletonlazy(){

    }
}
//单例模式(懒汉模式)

public class Demo17 {
    public static void main(String[] args) {
        Singletonlazy s1 = Singletonlazy.getInstance();
        Singletonlazy s2 = Singletonlazy.getInstance();
        System.out.println(s1 == s2);
    }
}

 前面都是铺垫,接下来进入正题:

在刚才的这两种模式中,谁是线程安全的,也就是说,在多线程调用 getInstance 的情况下,哪个代码是线程安全(不会有 bug)

对于饿汉模式:在多线程中,读取 instancece 的内容,多线程读取同一个变量,是没有问题的!

对于懒汉模式:  这时就会出现问题了

此时,SingletonLazy 就会被 new 出两个对象,就不再是单例了

这时候就会有疑问了:第二次 new 操作,不就是把原来的instance 的引用给修改了吗?之前 new 出来的对象不是立即被回收了吗,最后不还是剩一个对象吗?

注意:new 一个对象,它的开销可能是非常大的,new 了多次,就花费了多个开销。

结论:懒汉模式代码中,不是线程安全的!!!


(3)解决懒汉模式的线程安全问题

我们可以通过加锁的方式来解决,于是便会有了下面这段代码:

但是这样的写法是错误的 并没有解决上述线程安全问题。是因为这个操作本身就是一个原子的,再进行加锁并没有起到实质性上的改变

那么要怎样修改呢?

把锁加到外面;

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

这样,就解决了上述问题,但是还存在着别的问题:

加锁是一个成本比较高的操作,加锁可能会引起阻塞等待。

加锁的基本原则应该是:非必要不加锁,不能无脑加锁。如果无脑加锁,就会导致程序的执行效率受到影响 

这种写法这意味着,后续每次调用 GetInstance 都要进行加锁,但是它并不是必要的

懒汉模式的线程不安全,主要是在首次 new 对象的时候,才存在问题,一旦把对象 new 好了之后,后续再调用 getInstance ,就没有问题了。

也就是说,一旦 new 了一个对象,后续 if 条件就进不去了,也就不会涉及到修改操作,全是读操作。

而这种写法,在首次调用和后续调用都进行了加锁,实际上后续调用不必加锁,这里把不该加锁的地方给加锁了,就会很影响程序的效率

那么怎么解决这样的问题呢?

先判定是否要加锁,再决定是不是真的加锁

    public static Singletonlazy getInstance(){
        // instance 如果为空,就说明是首次调用,需要加锁,如果是非null,就说明是后续调用,不用加锁
        if (instance == null){
            synchronized (Singletonlazy.class){
                if (instance == null){
                    instance = new Singletonlazy();
                }
            }
        }

        return instance;
    }

同一个 if 条件,写了两遍,是否合理呢?

非常合理!!!

这是因为,加锁操作可能会阻塞,阻塞多久是无法确定的。在第二个条件和第一个条件之间,可能会间隔非常长的时间,在这个很长的时间间隔中,可能别的线程就把 instance 给改了

第一个条件:判定是否要加锁

第二个条件:判定是否要创建对象


(4)懒汉模式的内存可见性问题

假设现在有两个线程在同时执行,第一个线程在修改完 instance 之后,就结束了代码块,释放了锁,第二个线程就能够获取到锁了,从阻塞中恢复了,那么第二个线程进行的读操作,一定能够读取到第一个线程修改之后的值吗? 

这也就是 内存可见性 问题 

此处,我们就需要给 instance 加上一个 volatile 

这里究竟会不会有内存可见性问题,我们只是分析了 可能会存在这样的风险,但是编译器实际上在这个场景中是否会触发优化是不确定的。

这里其实和前面的代码有本质区别:前面的内存可见性是一个线程反复读,现在是多个线程反复读,这时是否会触发前面的优化,是未知的,但是加上 volatile 是更加稳妥的做法

此处加上 volatile 还有另一个用途:避免此处赋值操作的 指令重排序

指令重排序也是编译器优化的一种手段:保证原有执行逻辑不变的前提下,对代码执行顺序进行调整,使调整之后的执行效率提高

如果使单线程,这样的重排序一般没事,如果使多线程,就可能会出现问题了

这个操作涉及到下面三个步骤: 

1、给对象创建出内存空间,得到内存地址

2、在空间上调用构造方法,对对象进行初始化

3、把内存地址赋值给 instance 引用

此处就可能会涉及到指令重排序,123 可能就变成了 132,如果是单个线程,此时先执行 2 还是先执行 3是无所谓的,但是多线程就不一定了 

还是假设有两个线程同时执行

假设第一个线程此处是按照 1 3 2的顺序执行的,并且在执行 3 之后,2 之前,出现了线程切换,此时还没来得及给对象初始化,就调度给别的线程了

接下来第二个线程执行的,判定 instance != null,于是直接返回 instance ,并且后续可能会使用 instance 中的一些属性或者方法,但是此处拿到的对象是一个不完整的,没有被初始化的对象,针对这个不完整的对象使用 属性 / 方法,就可能会出现一些状况

给 instance 加上 volatile 之后,针对 instance 进行的赋值操作,就不会产生上述的指令重排序了,必然按照 123 的顺序执行,而不是按照 132 了

class Singletonlazy{
    private static volatile Singletonlazy instance = null;

    public static Singletonlazy getInstance(){
        // instance 如果为空,就说明是首次调用,需要加锁,如果是非null,就说明是后续调用,不用加锁
        if (instance == null){
            synchronized (Singletonlazy.class){
                if (instance == null){
                    instance = new Singletonlazy();
                }
            }
        }

        return instance;
    }

    private Singletonlazy(){

    }
}

2、阻塞队列

阻塞队列带有阻塞功能:

1、当队列满的时候,继续入队列,就会出现阻塞,阻塞到其它线程从队列中取走元素为止

2、当队列空的时候,继续出队列,也会出现阻塞,阻塞到其它线程往队列中添加元素为止

阻塞队列的用处非常大,在后端开发中,基于阻塞队列,可以实现生产者消费者模型

生产者消费者模型是一种处理多线程问题的方式,举个例子:

包饺子有三个步骤:1、和面   2、擀饺子皮   3、 包饺子

过年的时候,一家人在一起包饺子,有下面两种策略:

1、每个人,负责擀饺子皮,擀好一个就包一个,这种做法就比较低效

2、一个人专门负责擀饺子皮,另外两个人负责包饺子【生产者消费者模型】

生产者:擀饺子皮的人        消费者:包饺子的人        交易场所:放饺子皮的地方

生产者消费者模型的优势

1、解耦合

解耦合,就是“降低模块之间的耦合”

考虑一个分布式的系统:

当机房中只有 A 和 B 的时候,A 是直接把请求发给 B 的,A  和 B 之间的耦合就比较明显

(1)如果 B 挂了,就可能会对 B 造成很大的影响,如果 A 挂了,也会对 B 造成很大的影响

(2)假设此时添加一个服务器 C ,此时就需要对 A 的代码,进行较大的改动

此时,如果引入生产者消费者模型,引入一个阻塞队列,就能有效解决上述问题

此时,A 和 B 就通过阻塞队列,很好的解耦合了

此时如果 A 或者 B 挂了,由于它们彼此之间不会直接交互,没有啥太大的影响

如果要新增一个服务器 C ,此时 A  服务器完全不需要任何修改,只要让 C 也从队列中取元素即可

2、削峰填谷

服务器受到的来自于 客户端 / 用户 的请求,不是一成不变的,可能会因为一些突发事件,引起请求数目暴增

一台服务器,同一时刻能处理的请求数量是有上限的,不同服务器承担的上限是不一样的

在一个分布式系统中,就会经常出现,有的及其能承担的压力更大,有的则更小

此时, A 每次接收到一个请求, B 也就需要立即处理一个请求

如果 A  能够承受的压力大,B 能够承受的压力小的话,B 就可能先挂了

但是如果使用生产者消费模型,就另当别论

当外界的请求突然暴涨, A 收到的请求多了,A 就会给队列中写入更多的请求数据,但是 B 仍然可以按照原来既定的节奏来处理请求,不至于挂了

相当于队列起到了一个缓冲的作用,队列把本来是 B 的压力给承担起来了(削峰)

峰值很多时候只是暂时的,当峰值消退了,A 受到的请求少了,B 还是按照既定的节奏来处理,B 也不至于太闲(填谷)

正因为生产者消费者模型这么重要,虽然阻塞队列只是一个数据结构,我们会把这个数据结构单独实现成一个服务器程序并且使用单独的 主机 / 主机集群 来部署,此时,这个所谓的阻塞队列,就进化成了 “消息队列”

java 标准库已经提供了现成的阻塞队列的实现

 Array 这个版本的速度要更快,但前提是得知道最多有多少个元素

如果不知道有多少个元素,使用 Linked 更合适(对于 Array 来说,频繁扩容是一个不小的开销)

对于 BlockingQueue 来说,offer 和 poll 不带有阻塞功能,put 和 take 带有阻塞功能

基于阻塞队列写一个简单的生产者消费者模型:

一个线程生产,一个线程消费

//生产者消费者模型

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingDeque;

public class Demo19 {
    public static void main(String[] args) {
        //搞一个阻塞队列,作为交易场所
        BlockingQueue<Integer> queue = new LinkedBlockingDeque<>();

        //负责生产元素
        Thread t1 = new Thread(() ->{
            int count = 0;
            while(true){
                try {
                    queue.put(count);
                    System.out.println("生产元素: " + count);
                    count++;

                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        //负责消费元素
        Thread t2 = new Thread(() ->{
            while(true){
                try {
                    Integer n = queue.take();
                    System.out.println("消费元素: " + n);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        t1.start();
        t2.start();
    }
}

自己实现一个阻塞队列:

此处,基于数组,循环队列,来实现阻塞队列

我们将数组首尾相连,形成了一个圆环:当 head 和 tail 重合的时候,到底是 空 还是 满 呢?

(1)浪费一个格子,当 tail 走到 head 的前一个位置,就认为是满了

(2)单独搞一个变量,用单独的变量去表示当前的元素个数

现在,我们已经实现了一个简单的队列:

class MyBlockingQueue{
    //使用一个 String 类型的数组来保存元素,假设这里只存 String
    private String[] items = new String[1000];
    //指向队列的头部
    private int head = 0;
    //指向队列的尾部的下一个元素,总的来说,队列中的有效元素范围:[head,tail)
    //当 head 和 tail 相等(重合),相当于空的队列
    private int tail = 0;
    //使用 size 来表示元素个数
    private int size = 0;

    //入队列
    public void put(String elem){
        if (size >= items.length){
            //队列满了
            return;
        }
        items[tail] = elem;
        tail++;
        if (tail >= items.length){
            tail = 0;
        }
        size++;
    }
    //出队列
    public String take(){
        if (size == 0){
            //队列为空,暂时不能出队列
            return null;
        }
        String elem = items[head];
        head++;
        if (head >= items.length){
            head = 0;
        }
        size--;
        return elem;
    }
}

接下来,我们基于上面这个普通的队列将其改造成阻塞队列

(1)线程安全

先给 put 和 take 进行加锁,保证在多线程调用的时候,能够保证线程安全

于是我们直接在 put 和 take 上直接加上 synchronized 

除了加锁之外,还要考虑内存可见性问题

此时,我们将 head 、 tail 、 size ,都加上volatile

(2)实现阻塞

(1)当队列满的时候,再进行 put 就会产生阻塞

(2)当队列空的时候,再进行 take 也会产生阻塞

此处的两个 wait 不会同时出现,要么这边 wait ,要么是另一边 wait 

在上述代码中,满足条件,就进行 wait ,当 wait 被唤醒之后,条件就一定被打破了吗?

比如,当前,我这边的 put 操作因为队列满了,wait阻塞了,过了一阵,wait被唤醒了,唤醒的时候,此时的队列就一定不满了吗?是否可能队列还是满着的呢?

万一出现唤醒之后,队列还是满着的,此时意味着接下来的代码继续执行,就可能把之前存入的元素给覆盖了

在当前的代码中,如果是 interrupt 唤醒的,此时直接引起异常,方法就结束了,不会继续执行,也就不会导致刚才所说的覆盖已有元素的问题

但是如果是按照 try-catch 的方式来写,此时一旦是 interrupt 唤醒,此时代码往下走,进入 catch ,catch 执行完毕,代码不会结束,而是继续往下执行,也就触发了 “覆盖元素” 逻辑

上述分析发现,这个代码只是侥幸写对了,稍微变换一下,就会出现问题,实际这么写的时候,很难注意到这个细节,是否有办法让这个代码万无一失,即使按照 try-catch 的方式写,也没事呢?

只需要让 wait 醒了之后,再判断一次条件

如果条件还是为队列满,继续 wait ,如果条件为队列不满,就可以继续执行了

此处的 while 目的不是为了循环,而是借助循环的方式,巧妙地实现 wait 醒了之后,再次确认一下条件 

所以使用 wait 的时候,建议搭配 while  进行条件判定

最终代码如下: 

class MyBlockingQueue{
    //使用一个 String 类型的数组来保存元素,假设这里只存 String
    private String[] items = new String[1000];
    //指向队列的头部
    volatile private int head = 0;
    //指向队列的尾部的下一个元素,总的来说,队列中的有效元素范围:[head,tail)
    //当 head 和 tail 相等(重合),相当于空的队列
    volatile private int tail = 0;
    //使用 size 来表示元素个数
    volatile private int size = 0;

    //入队列
    public synchronized void put(String elem) throws InterruptedException {
        while (size >= items.length){
            //队列满了,
            this.wait();
        }
        items[tail] = elem;
        tail++;
        if (tail >= items.length){
            tail = 0;
        }
        size++;
        //用来唤醒队列为 空 的阻塞情况
        this.notify();
    }
    //出队列
    public synchronized String take() throws InterruptedException {
        while (size == 0){
            //队列为空,暂时不能出队列
            this.wait();
        }
        String elem = items[head];
        head++;
        if (head >= items.length){
            head = 0;
        }
        size--;
        //使用这个 notify 来唤醒队列满的阻塞情况
        this.notify();
        return elem;
    }
}

3、定时器

定时器也是日常开发中常见的组件,类似于一个闹钟

TimerTask 类似于之前学过的 Runnable ,会记录一个时间:当前的任务什么时候去执行

给 timer 中注册的这个任务,不是在调用 schedule 的线程中执行的,而是通过 Timer 内部的线程来负责执行的

import java.util.Timer;
import java.util.TimerTask;

public class demo21 {
    public static void main(String[] args) {
        Timer timer = new Timer();
        //给 timer 中注册的这个任务,不是在调用 schedule 的线程中执行的,而是通过 Timer 内部的线程来负责执行的
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("定时任务执行");
            }
        },3000);
        System.out.println("程序开始运行");
    }
}

我们会发现,当运行结果之后,代码并没有在打印完毕之后结束进程:

 这是因为:

Timer 内部,有自己的线程,为了保证随时可以处理新安排的任务,这个线程会持续执行,并且这个线程还是个前台线程 

接下来,尝试自己实现一个定时器:

一个定时器,是可以有很多个任务的!

先要能够把一个任务给描述出来,再使用一个数据结构把多个任务组织起来

(1)创建一个 TimerTask 这样的类,表示一个任务,这个任务就需要包含两个方面:任务的内容和任务的实际执行时间

任务的实际执行时间,可以使用时间戳表示,再 schedule 的时候,先获取到当前的系统时间,再这个是基础上,加上 delay 的时间间隔,得到了真实要执行这个任务的时间

(2)使用一定的数据结构,把多个 TimerTask 给组织起来

如果使用 List (数组、链表) 组织 Timertask ,如果任务特别多,如何确定哪个任务,何时能够执行呢?

这样就需要搞一个线程,不停的对上述的 List 进行遍历,看这里的每个元素是否到了时间,时间到了就执行,时间没到就跳过,

但是这个思路并不科学,如果这些任务的时间都还为时尚早,在时间到达之前,此处的这个扫描线程就需要一刻不停的反复扫描,这样的过程就非常的低效

(a)并不需要扫描所有的任务,只需要盯住时间最靠前的任务即可

最早的任务时间还没到的话,其他的任务时间更不会到

把遍历所有任务,改进成了只关注一个

那么如何知道哪个任务是最靠前的呢?

使用优先级队列,来组织所有的任务,最合适不过了,队首元素,就是时间最小的任务

(b)针对这一个任务的扫描,也不必一直反复执行

而是在获取到队首元素的时间之后,和当前的系统时间,做一个差值,根据这个差值来决定 休眠 / 等待 的时间,在这个时间到达之前,不会进行重复扫描,大幅度降低了扫描的次数

此处 “提高效率” 不是缩短执行时间,而是减少了资源的利用率, 避免了不必要的 cpu 浪费

(c)搞一个扫描线程,负责监控队首元素的任务是否已经时间到

这个代码的核心流程如下:

import java.util.PriorityQueue;

//创建一个类,用来描述定时器中的一个任务
class MyTimerTask{
    //任务啥时候执行,毫秒级的时间戳
    private long time;
    //任务具体是啥
    private Runnable runnable;

    public long getTime() {
        return time;
    }

    public Runnable getRunnable() {
        return runnable;
    }

    public MyTimerTask(Runnable ruunnabl, long delay){
        //delay 是一个相对的时间差
        //构造 time 要根据当前系统时间和 deley 进行构造
        time = System.currentTimeMillis() + delay;
        this.runnable = ruunnabl;
    }
}

//定时器类的本体
class MyTimer{
    //使用优先级队列,来保存上述的 N 个任务
    private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();

    //定时器的核心方法,就是把要执行的任务给添加到队列中
    public void schedule(Runnable runnable,long delay){
        MyTimerTask task = new MyTimerTask(runnable,delay);
        queue.offer(task);
    }

    //MyTimer 中,还需要构造一个 “扫描线程” ,一方面去负责监控队首元素是否到点了们是否应该执行;一方面当任务到店之后
    // 就要调用这里的 Runnable 的 Run 方法来完成任务
    public MyTimer(){
        //扫描线程
        Thread t = new Thread(() ->{
            while(true){
                if (queue.isEmpty()){
                    //注意,当前队列为空,此时就不应该去取这里的元素
                    continue;
                }
                MyTimerTask task = queue.peek();
                long curTime = System.currentTimeMillis();
                if (curTime >= task.getTime()){
                    //需要执行任务
                    queue.poll();
                    task.getRunnable().run();
                }else {
                    //让当前扫描线程休眠一下,就可以按照时间差进行休眠
                    try {
                        Thread.sleep(task.getTime() - curTime);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        });
        t.start();
    }
}

上述代码中,写完了定时器的核心逻辑,但是这份代码中还存在几个比较关键的问题:

1、不是线程安全的

 这个集合类,不是线程安全的,既会在主线程中使用,又会在扫描线程中使用

给针对 queue 的操作进行加锁

2、扫描线程中,直接使用 sleep 进行休眠是否合适

不合适

(a)sleep 进入阻塞后,不会释放锁,影响到其他的线程,执行到这里的 schedule

(b)sleep 在休眠的过程中,不方便提前中断(虽然可以使用 interrupt 来中断,但是 interrupt 意味着线程应该要结束了)

假设当前最靠前的任务是 14:00 执行,当前时刻是 13:00(sleep 一小时),此时如果我们新增一个新的任务,新的任务是 13:30 要执行的,此时新的任务就成了最早要执行的任务了

我们希望,每次来新的任务,都能把之前的休眠状态给唤醒,并且根据当前最新的任务状况,重新进行判定

此时,扫描线程就可以按照 30 分钟,来进行阻塞等待

相比之下,wait 就更合适一些:wait也可以指定超时时间,wait也可以提前被唤醒

3、随便写一个类,它的对象都能放到优先级队列中吗?

要求放到优先级队列中的元素,使“可比较的”

通过 Comparable 或者 Comparator 定义任务之间的比较规则

此处,就在 MyTimerTask 这里,实现Comparable

最终的代码如下:

import java.util.PriorityQueue;

//创建一个类,用来描述定时器中的一个任务
class MyTimerTask implements Comparable<MyTimerTask>{
    //任务啥时候执行,毫秒级的时间戳
    private long time;

    @Override
    public int compareTo(MyTimerTask o) {
        //把时间小的,优先级高,最终时间最小的元素,就会放到队首
        return (int) (this.time - o.time);
    }

    //任务具体是啥
    private Runnable runnable;

    public long getTime() {
        return time;
    }

    public Runnable getRunnable() {
        return runnable;
    }

    public MyTimerTask(Runnable ruunnabl, long delay){
        //delay 是一个相对的时间差
        //构造 time 要根据当前系统时间和 deley 进行构造
        time = System.currentTimeMillis() + delay;
        this.runnable = ruunnabl;
    }
}

//定时器类的本体
class MyTimer{
    //用来加锁的对象
    private Object locker = new Object();

    //使用优先级队列,来保存上述的 N 个任务
    private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();

    //定时器的核心方法,就是把要执行的任务给添加到队列中
    public void schedule(Runnable runnable,long delay){
        synchronized (locker)
        {
            MyTimerTask task = new MyTimerTask(runnable,delay);
            queue.offer(task);
            //每次来新的任务,都唤醒一下之前的扫描线程,好让扫描线程根据最新的额任务情况重新规划等待时间
            locker.notify();
        }
    }

    //MyTimer 中,还需要构造一个 “扫描线程” ,一方面去负责监控队首元素是否到点了们是否应该执行;一方面当任务到店之后
    // 就要调用这里的 Runnable 的 Run 方法来完成任务
    public MyTimer(){
        //扫描线程
        Thread t = new Thread(() ->{
            while(true){
                synchronized (locker){
                    while (queue.isEmpty()){
                        //注意,当前队列为空,此时就不应该去取这里的元素
                        //此处使用 wait 等待更合适,如果使用 continue ,就会使这个线程的 while 循环运行的飞快
                        //也会陷入一个高频占用 cpu 的状态(忙等)
                        try {
                            locker.wait();
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                    MyTimerTask task = queue.peek();
                    long curTime = System.currentTimeMillis();
                    if (curTime >= task.getTime()){
                        //需要执行任务
                        queue.poll();
                        task.getRunnable().run();
                    }else {
                        try {
                            //让当前扫描线程休眠一下,就可以按照时间差进行休眠
                            locker.wait(task.getTime() - curTime);
                        } catch (InterruptedException e) {
                            throw new RuntimeException(e);
                        }
                    }
                }
            }
        });
        t.start();
    }
}

4、线程池

池(pool) 使一个非常重要的方法

如果我们需要频繁的创建销毁线程,此时创建销毁线程的成本,就不能忽视了

因此就可以使用线程池,提前创建好一波线程,后续需要使用线程就直接从池子里拿一个即可,当线程不再使用,就放回池子里

本来是需要 创建 / 销毁 线程,现在是从池子里获取到现成的线程,并且把线程归还到池子里

为何什么从池子里取,就比从系统这里创建线程更快更高效呢?

如果是从系统这里创建线程,就需要调用系统 api,进一步的由操作系统内核完成线程的创建过程

内核是给所有的进程提供服务的,所以这种是不可控的 

如果是从线程池这里获取线程,上述的内核中进行的操作,都提前做好了,现在的取线程的过程,纯粹的用户代码完成(纯用户态)可控的

Java 标准库中,也提供了现成的线程池

 工厂模式:生产对象

一般创建对象,都是通过 new ,通过构造方法

但是构造方法存在重大缺陷:构造方法的名字固定就是类名

有的类,它需要多种不同的构造方式,但是构造方法名字又固定,只能使用方法重载的方式来实现了(参数的个数和类型需要有差别)

就比如:现在我们想要按照两种方式进行构造,一个是按照笛卡尔积坐标构造,一个是按照极坐标构造,这两种构造方式,参数的个数和类型是一样的,无法构成重载,就会编译报错

此时就可以使用工厂模式来解决上述问题了

使用工厂模式来解决上述问题:不使用构造方法了,使用普通的方法来构造对象,这样的方法名字就可以是任意的了,在普通方法的内部,再来 new 对象

由于普通方法的目的是为了创建出对象来,所以这样的方法一般是静态的

线程池对象搞好了之后,使用 submit 这样的方法,就可以把任务添加到线程池中

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

//线程池
public class Demo23 {
    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(4);
        for (int i = 0;i < 100;i++){
            service.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello");
                }
            });
        }
    }
}

除了上述这些线程池之外,标准库还提供了一个接口更丰富的线程池:ThreadPoolExecutor

之前的几个线程池,为了使用方便,已经做了封装了,ThreadPoolExecutor 这个线程池有很多可以供我们调整的选项,可以更好的满足需求

谈谈 java 标准库中的线程池构造方法的参数和含义

如果把线程池比喻成一个公司,核心线程数就是正式员工的数量

最大线程数就是正式员工+ 实习生 的数量

当公司业务不忙的时候,就不需要实习生,当公司业务繁忙的时候,就可以找一些实习生来分担任务

可以做到,既能保证繁忙的时候高效处理任务,又能保证空闲的时候,不会浪费资源


实习生线程,空闲超过一定的阈值,就可以被销毁了


线程池内部有很多任务,这些任务,可以使用阻塞队列来管理 

线程池可以内置阻塞队列,可以可以手动指定一个


工厂模式,通过这个工厂类来创建线程 


这个是线程池考察的重点: 拒绝方式 / 拒绝策略

线程池有一个阻塞队列,当阻塞队列满了之后,继续添加任务,该如何应对


第一个拒绝策略:

直接抛出异常,线程池就不干活了 


第二个拒绝策略:

谁是添加这个新的任务的线程,谁就去执行这个任务


第三个拒绝策略:

丢弃最早的任务,执行新的任务


第四个拒绝策略:

直接把新的任务给丢弃了,仍然继续之前的任务

上面谈到的线程池,有两组:一组线程池是封装过的 Executors ,一组线程池是原生的 ThreadPoolExecutor,用哪个都可以,主要还是看实际需求

接下来,我们尝试自己模拟实现一个线程池:


import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingDeque;

class MyThreadPool{
    private BlockingQueue<Runnable> queue = new LinkedBlockingDeque<>();

    //通过这个方法,来把任务添加到线程池中
    public void submit(Runnable runnable) throws InterruptedException {
        queue.put(runnable);
    }

    // n 表示线程池里面有几个线程
    //创建了一个有固定数量的线程池
    public MyThreadPool(int n){
        for(int i = 0;i < n;i++){
            Thread t = new Thread(() ->{
                while(true){
                    //循环的取出任务,并执行
                    try {
                        Runnable runnable = queue.take();
                        runnable.run();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            });
            t.start();
        }
    }
}

//线程池
public class Demo24 {
    public static void main(String[] args) throws InterruptedException {
        MyThreadPool pool = new MyThreadPool(4);
        for(int i = 0;i < 1000;i++){
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    //要执行的工作
                    System.out.println(Thread.currentThread().getName() + " hello");
                }
            });
        }
    }
}

由于此时的调度是随机的,当前给这个线程池中插入的任务,在执行的时候,也不一定是 N 个线程的工作量完全均等,但是从统计意义上,任务量是均等的

此外,还有一个比较重要的问题:

创建线程池的时候,线程的个数是怎么来的?

不同的项目中,线程要做的工作是不一样的

有的线程的工作,是 “CPU密集型”,线程的工作全是运算

大部分工作都是要在 CPU 上完成的,CPU 得给它安排核心去完成工作才可以有进展,如果 CPU 是 N 个核心,当你线程数量也是 N 的时候,理想情况是每个核心上一个线程

如果该很多的线程,线程也就是在排队等待,不会有新的进展

有的线程的工作,是“IO密集型”,读写晚间,等待和用户输入,网络通信

涉及到大量的等待时间,等的过程中,没有使用 cpu,这样的线程就算更多一下,也不会给 cpu 造成太大的负担

实际开发中,一个线程往往是一部分工作是 cpu密集的,一部分工作是 io密集的

此时,一个线程,有多少在 cpu 上运行,又有多少是在等待 io,是说不准的

这里更好的做法,是通过实验的方式,来找到合适的线程数,通过性能测试,尝试不同的线程数目,在尝试过程中,找到性能和系统资源开销比较均衡的数值


六、常见的锁策略

接下来讲解的锁策略不仅仅是局限于 Java . 任何和 " " 相关的话题 , 都可能会涉及到以下内容 .
些特性主要是给锁的实现者来参考的 .
锁策略,指的不是某个具体的锁,是一种抽象的概念,描述的是锁的特性

1、乐观锁 VS 悲观锁

乐观锁:预测该场景中,不太会出现锁冲突的情况

悲观锁:预测该场景,非常容易出现锁冲突

锁冲突:两个线程尝试获取一把锁,一个线程能获取成功,另一个线程阻塞等待

锁冲突的概率 大 还是 小,对后续的工作,是有一定影响的


2、重量级锁 VS 轻量级锁

重量级锁:加锁的开销是比较大的(花的时间多,占用系统资源少) 

轻量级锁:加锁的开销是比较小的(花的时间少,占用系统资源少)

一个悲观锁,很可能是重量级锁(不绝对)

一个乐观锁,也很可能是轻量级锁(不绝对)

悲观乐观,是在加锁之前对锁冲突概率的预测,决定工作的多少;重量轻量,是在加锁之后考量实际的锁的开销

正式因为这样的概念存在重合,针对一个具体的锁,可能把它叫做乐观锁,也可能叫做轻量级锁


3、自旋锁 VS 挂起等待锁

自旋锁:是轻量级锁的一种典型实现

往往是指,在用户态下,通过自选(while循环),实现类似加锁的效果

会消耗一定的 cpu 资源,但是可以做到最快速度拿到锁 

挂起等待锁:是重量级锁的一种典型实现

通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,会牵扯到内核对于线程的调度,使冲突的线程出现挂起(阻塞等待)


4、读写锁 VS 互斥锁

读写锁:把读操作加锁和写操作加锁分开了

多线程同时去读同一个变量,不涉及到线程安全问题

如果两个线程,一个线程读加锁,另一个线程也是读加锁不会产生锁竞争

如果两个线程,一个线程写加锁,另一个线程也是写加锁会产生锁竞争

如果两个线程,一个线程写加锁,另一个线程也是读加锁会产生锁竞争

实际开发中,读操作的频率,往往比写操作,要高很多 

java 标准库中,也提供了现成的读写锁


5、公平锁 VS 非公平锁

此处定义,公平锁是遵循先来后到的锁

非公平锁,看起来是概率均等,但是实际上是不公平(每个线程阻塞时间是不一样的)

操作系统自带的锁(pthread_mutex),属于非公平锁

要想实现公平锁,就需要有一些额外的数据结构来支持(比如需要有办法记录每个线程的阻塞等待时间)


6、可重入锁 VS 不可重入锁

如果一个线程,针对一把锁,连续加锁两次,就会出现死锁,就是不可重入锁

如果代码这么写,是否会出现问题呢?

1、调用方法,先针对 this 加锁,假设此时加锁成功了

2、接下来往下执行到代码块中的 synchronized ,此时,还是针对 this 进行加锁

此时,就会产生锁竞争,因为当前 this 这个对象已经处于加锁状态了

此时该线程就会阻塞,阻塞到锁被释放,才能有机会拿到锁

在这个代码中,this 上的锁,得在 increase 方法执行完毕之后才能释放,但是得第二次加锁成功获取到锁,方法才能继续执行

要想让代码继续向下执行,就需要把第二次的加锁获取到,也就是把第一次加锁释放,要想把第一次加锁释放,又需要保证代码继续执行

此时,由于 this 的锁没办法释放,这个代码就卡在这里了,这个线程就僵住了(死锁的第一个体现形式)

如果是一个 不可重入锁 ,这把锁不会保存是哪个线程对它加的锁,只要它收到了 “加锁” 这样的请求,就会拒绝当前的加锁,而不管当下的线程时哪个,就会产生死锁

可重入锁,则是会保存,是哪个线程加上的锁,后续受到加锁请求后,就会先对比一下,看看加锁的线程是不是当前持有这把锁的线程,这个时候就可以灵活判定了

synchronized 本身是一个可重入锁,因此这个代码并不会出现死锁的情况

可重入锁,是让锁记录一下当前是哪个线程持有了锁

如果加锁是 N 层,在遇到 } 的时候, JVM 怎么知道 当前这个 } 是不是最外层的那一个呢?

让锁这里持有一个计数器就行了 ,让锁对象不光要记录是哪个线程持有的锁,同时再通过一个整形变量记录当前这个线程加了几次锁

每遇到一个加锁操作,就计数器 +1,每遇到一个解锁操作,就计数器 -1

当计数器被减为 0 的时候,才真正执行释放锁操作,其它时候不释放

我们将这种计数称为 “引用计数”


七、死锁

1、一个线程,一把锁,但是是不可重复锁,该线程针对这个锁连续加锁两次,就会出现死锁

2、两个线程,两把锁,这两个线程先分别获取到一把锁,然后再同时尝试获取到对方的锁

3、N 个线程, N 把锁

通过代码感受一下死锁;

public class Demo25 {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() ->{
            synchronized (locker1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker2){
                    System.out.println("t1 两把锁加锁成功");
                }
            }
        });
        
        Thread t2 = new Thread(() ->{
            synchronized (locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker1){
                    System.out.println("t2 两把锁加锁成功");
                }
            }
        });

        t1.start();
        t2.start();
    }
}

这段代码就是典型的第二种死锁,运行后我们会发现两个线程都没能成功的打印出结果

 我们可以打开 jconsole 进一步的观察线程

我们会发现:这两个线程都是卡在了进一步加锁的地方

如果是一个服务器程序,出现死锁,死锁的线程就僵住了,就无法继续工作了,,会对程序造成严重的影响

3、N 个线程, N 把锁

哲学家就餐问题:

假设现在有五个哲学家,桌上有 5 根筷子 

每个哲学家,主要要做两件事:

1、思考人生,会放下筷子

2、吃面,会拿起左手和右手的筷子,再去夹面条吃

 其它设定:

1、每个哲学家什么时候思考人生,什么时候吃面条,是不确定的

2、每个哲学家,一旦想吃面条了,就会非常固执的完成这个吃面条的操作,如果它的筷子被别人使用了,就会阻塞等待,而且等待过程中,不会放下手里已经拿着的筷子

基于上述的模型设定,这些哲学家,都是可以很好的运转的

但是,如果出现了极端情况,就会出现死锁

假设在同一时刻,五个哲学家都想吃面,并且同时伸出左手,拿起左边的筷子,再尝试拿右边的筷子

5 个哲学家,就是 5 个线程,5 个筷子,就是 5 把锁

哲学家模型就很生动形象的描述了第三种死锁问题

那么是否有办法避免死锁呢?

先明确一下死锁产生的原因,死锁的必要条件

四个必要条件:(缺一不可)

只要能够破坏其中任意一个条件,都可以避免出现死锁

1、互斥使用,一个线程获取到一把锁之后,别的线程不能获取到这个锁

实际使用的锁,一般都是互斥的(锁的基本特性)

2、不可抢占,锁只能是被持有者主动释放,而不能是被其它线程抢走

也是锁的基本特性

3、请求和保持,一个线程去尝试多把锁,在尝试获取第二把锁的过程中,会保持对第一把锁的获取状态

取决于代码结构(很可能会影响到需求)

4、循环等待,t1 尝试获取 locker2,需要 t2 执行完,释放 locker2,t2 尝试获取 locker1,需要 t1 执行完,释放 locker1

取决于代码结构(解决死锁问题的关键要点)

如何具体解决死锁问题?实际的方法有很多种(银行家算法)

银行家算法可以解决死锁问题,但是不太接地气

这里,我们介绍一个更简单,也更加有效的方式来解决死锁问题

针对锁进行编号,并且规定加锁的顺序

比如:规定每个线程如果要获取多把锁,必须先获取 编号小 的锁,后获取 编号大 的锁

只要所有的线程加锁的顺序,都严格遵循上述顺序,就一定不会出现加锁等待


八、synchronized原理

1、基本特点 

synchronized 具体是采用了哪些策略呢?

1、synchronized 既是悲观锁,又是乐观锁

2、synchronized 即使重量级锁,也是轻量级锁

3、synchronized 重量级锁部分是基于系统的互斥锁实现的,轻量级锁部分是基于自旋锁实现的

4、synchronized 是非公平锁(不会遵守先来后到,锁释放后,哪个线程拿到锁,各凭本事)

5、synchronized 是可重入锁(内部会记录哪个线程拿到了锁,记录引用计数)

6、synchronized 不是读写锁


2、加锁工作过程 

synchronized 的内部实现策略(内部原理)

代码中给写了一个 synchronized 之后,这里可能会产生一系列的“自适应的过程” ,锁升级(锁膨胀)

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

偏向锁,不是真的加锁,而只是做了一个 “标记”,如果有别的线程来竞争锁了,才会真的加锁,如果没有别的线程竞争,就自始至终都不会真的加锁了

加锁本身,是有一定开销的,能不加,就不加,非得是有人来竞争了,才会真的加锁

轻量级锁,synchronized 通过自旋锁的方式,来实现轻量级锁

这边把锁占据了,另一个线程就会按照自旋的方式,来反复查询当前的锁的状态是不是被释放了

但是,后续如果竞争这把锁的线程越来越多了(锁冲突更激烈了),这个时候,synchronized 就会从轻量级锁,升级成重量级锁

锁消除,编译器会智能的判定当前这个代码是否有必要加锁,如果你先写了加锁,但是实际上没有必要加锁,就会把加锁操作自动删除掉

编译器进行优化,是要保证优化之后的逻辑和之前的逻辑一致的

这就会让有的优化变得保守起来

我们程序员也不能全指望全靠优化来提高代码效率,我们自己也得发挥一些作用。判定何时加锁,也是一个非常重要的工作

锁粗化

 “锁的粒度”:如果加锁操作里包含的实际要执行的的代码越多,就认为锁的粒度越大

有的时候,我们希望锁的粒度小一些并发程度更高

有的时候,也希望锁的粒度大比较好,因为加锁解锁本身也有开销


 九、CAS

1、概念 

CAS: 全称 Compare and swap ,字面意思 :” 比较并交换
能够比较和交换 某个寄存器 中的值  和 内存 中的值,看是否相等 ,如果 相等,则把另外一个寄存器中的值和内存进行交换
boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false;
}

 这是一段伪代码

基于 CAS 又能衍生出一套 “无锁” 编程

CAS 的使用范围,是具有一定的局限性的


2、CAS 的应用

(1)实现原子类

比如,多线程针对一个 count 变量进行 ++

在 java 标准库中,已经提供了一组原子类

在原子类内部,并没有进行任何的加锁操作,只是通过 CAS 完成了线程安全的自增

import java.util.concurrent.atomic.AtomicInteger;

//使用原子类
public class Demo26 {
    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() ->{
           for(int i = 0;i < 50000;i++){
               //相当于 count++
               count.getAndIncrement();
               //相当于 ++count
               //count.incrementAndGet();
               //count--
               //count.getAndDecrement();
               //--count
               //count.decrementAndGet();
           }
        });

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

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count.get());
    }
}

上述原子类,就是基于 CAS 来实现的

伪代码实现:

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
       }
        return oldValue;
   }
}

当两个线程并发的执行 ++ 的时候,如果不加任何的限制,意味着,这两个 ++ 是串行的,能计算正确的,有的时候,这两个++ 操作时穿插的,这个时候时会出现问题的

加锁保证线程内存安全,通过锁,强制避免出现穿插 

原子类 / CAS 保证线程安全:借助 CAS 来识别当前是否出现 “穿插” 的情况,如果没穿插,此时直接修改,就是安全的;如果出现穿插了,就重新读取内存中最新的值,再次尝试修改

CAS 是一个指令,这个指令本身是不能拆分的

CAS 本身是一个单个的指令,这里其实包含了访问内存的操作,当多个 cpu 尝试访问内存的时候,本质上也是会存在先后顺序的

为什么这两个 CAS 访问内存会有先后呢?

多个 cpu 在操作同一个资源,也会涉及到锁竞争(指令级别的锁),这个锁是比我们平时所说的synchronized 代码级别的锁要轻量很多的


(2)实现自旋锁

基于 CAS 实现更灵活的锁 , 获取到更多的控制权 .
自旋锁伪代码:
public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}


 3、CAS 的 ABA 问题

CAS 关键要点,是比较 寄存器1 和 内存 的值,通过这里是否相等,来判断 内存的值 是否发生了改变

如果内存的值变了,存在其它的线程进行了修改

如果内存的值没变,没有别的线程修改,接下来进行的修改就是安全的

但是这里存在一个问题:如果这里的值没变,就一定没有别的线程修改吗?

A - B - A 问题:另一个线程,把变量从 A -> B,又从 B -> A ,此时本线程区分不了,这个值是始终没变,还是出现变化又变回来了的情况

大部分情况下,就算是出现了  A-B-A  问题,也没啥影响,但是如果遇到一些极端的场景,就不一定了

假设现在账户里面有 100 元,希望取款 50

假设出现极端情况:按第一下取款的时候,卡了一下,于是又按了一下(产生了两个取款请求, ATM 使用两个线程来处理这两个请求)

假设按照 CAS 的方式进行取款,每个线程这么操作:

1、读取账户余额,放到变量 M 中

2、使用 CAS 判定当前实际金额是否还是 M ,如果是,就把实际余额修改成 M - 50,如果不是,就放弃当前操作(操作失败)

当只有 t1 和 t2 两个线程进行取款 50 的操作的时候,由于 CAS ,所以只会成功扣款一次 

但是假设此时有另一个人向 账户中转账 50 ,使得在 t2 操作完成之后,余额变回了初始的 100 元,导致此时 t1 就错误的以为当前没有别的线程修改了余额,于是就继续执行了扣款操作,导致了重复扣款,此时就出现了 A - B - A 问题

虽然上述操作概率比较小,但是也需要去考虑

ABA 问题,CAS 的基本思路是 ok 的,但是主要是修改操作能够进行反复横跳,就容易让 CAS 的判定失效

CAS 判定的是 “值相同” ,实际上期望的是 “值没有变化过”

如果约定, 值只能单向变化(比如只能单向增长,不能减小),问题就迎刃而解了

但是账户余额似乎不能只增长,不减小

余额虽然不行,但是版本号可以!!!

此时,衡量余额是否改变,不是仅仅看余额的值了,而是看版本号!

给账户余额安排一个隔壁邻居:版本号(值增加,不减少)

使用 CAS 判定版本号,是否相同,如果版本号相同,则数据一定是没有修改过的,如果数据修改过,版本号一定要增加


十、JUC(java.util.concurrent) 的常见类

concurrent: 并发(多线程)

1、Collable interface

也是一种创建线程的方式

Runnable 能表示一个任务(run 方法),返回 void

Callable 也能表示一个任务(call 方法),返回一个具体的值,类型可以通过泛型参数来指定

如果进行多线程操作,如果只是关心多线程执行的过程,使用 Runnable 即可

像 线程池,定时器,都是只关系过程,使用 Runnable

如果是关心多线程的计算结果,使用 Callable 更合适

通过多线程的方式,来计算一个公式,使用 Callable 更合适

代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000, 不使用 Callable 版本
static class Result {
    public int sum = 0;
    public Object lock = new Object();
}
public static void main(String[] args) throws InterruptedException {
    Result result = new Result();
    Thread t = new Thread() {
        @Override
        public void run() {
            int sum = 0;
            for (int i = 1; i <= 1000; i++) {
                sum += i;
           }
            synchronized (result.lock) {
                result.sum = sum;
                result.lock.notify();
           }
       }
   };
    t.start();
    synchronized (result.lock) {
        while (result.sum == 0) {
            result.lock.wait();
       }
        System.out.println(result.sum);
   }
}
代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000, 使用 Callable 版本
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Demo27 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 1;i <= 1000;i++){
                    sum += i;
                }
                return sum;
            }
        };

        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();
        
        Integer result = futureTask.get();
        System.out.println(result);
    }
}

使用 Callable 的时候,不能直接使用 Thread 的构造方法参数

可以借助一个辅助的类: FutureTask 

 那么这个结果什么时候可以算出来呢?通过使用 FutureTask 就能解决这个问题:

类似于,去吃麻辣烫,付完钱之后,老板会给一张小票,当麻辣烫做好了之后,凭小票取餐,此处就是凭 FutureTask 来取结果


2、ReentrantLock

可重入互斥锁 . synchronized 定位类似 , 都是用来实现互斥效果 , 保证线程安全
R eentrantLock 也是可重入锁. "Reentrant" 这个单词的原意就是 "可重入"
这个锁,没有 synchronized 那么常用,但是也是一个可选的加锁的组件
这个锁在使用上,更接近于 C++里的锁

lock() 加锁          unlock() 解锁

ReentrantLock 具有一些特点,是 synchronized 不具备的功能:

1、提供了一个 tryLock 方法进行加锁

对于 lock 操作,如果加锁不成功,就会阻塞等待(死等) 

对于 trylock ,如果加锁失败,直接返回 false / 也可以设定等待时间

tryLock 给加锁操作提供了更多的可操作空间

2、ReentrantLock 有两种模式,可以在公平锁状态下,也可以工作在非公平锁状态下

可以在构造方法中通过参数设定的 公平 / 非公平模式

3、ReentrantLock 也有等待通知机制,搭配 Condition 这样的类来完成

这里的等待通知要比 wait notify 功能更强

这几个是 ReentrantLock的优势

但是 ReentranLock 的劣势也很明显:unlock 容易遗漏;因此我们可以使用 finally 来执行 unlock

synchronized 锁对象是任意对象,而 ReentrantLock 锁对象就是自己本身

换句话说,如果多个线程针对不同的 ReentrantLock 调用 lock 方法,此时是不会产生锁竞争的

实际开发中,进行多线程开发,用到锁,还是首选synchronized 


3、原子类

大部分内容,已经在前面讲过了

原子类的应用场景有哪些呢?

(1)计数需求

播放量、点赞量、投币量、转发量、收藏量......

同一个视频,有很多人都在同时的播放 / 点赞 / 收藏......

(2)统计效果

统计出现错误的请求数目,使用原子类

记录出错的请求的数目,再另外写一个监控服务器,获取到显示服务器的这些错误计数,并且通过曲线图的方式绘制到页面上

如果再某次发布程序之后,发现突然这里的错误数大幅度上升,说明这版本的代码大概率存在 bug!


4、信号量 Semaphore

信号量在操作系统中也经常出现

Semaphore 是并发编程中的一个重要概念 / 组件

准确来说,Semaphore 是一个计数器(变量),描述了可用资源,临界资源的个数

临界资源:多个进程 / 线程 等并发执行的实体可以公共使用到的资源(多个线程修改同一个变量,这个变量就可以认为是临界资源)

描述的是,当前这个线程是否有临界资源可以用

停车场的入口,一般会有一个显示屏:该停车场还有 XX 个空位,其中这个数据就是信号量,而这个空余车位就是可用资源

如果我开车进入停车场,就相当于,申请了一个车位(申请了一个可用资源),此时计数器就要 -1,称为 P 操作,accquire(申请)

如果我开车出了停车场,就相当于,释放了一个车位(释放了一个可用资源),此时计数器就要 +1,称为 V 操作,release(释放)

当计数器为 0 的时候,继续进行 P 操作,就会阻塞等待,一直等待到其它线程执行了 V 操作,释放了一个空闲资源为止

这个阻塞等待的过程,有点像锁

锁,本质上是一个特殊的信号量(里面的数值,非 0 即 1)

信号量要比锁更加广义,不仅仅可以描述一个资源,还可以描述 N 个资源

虽然概念上更广泛,但是实际开发中还是锁更多一些(二元信号量的场景是更常见的)


5、CountDownLatch

针对特定场景的一个组件

比如:下载一个东西

有的时候,下载一个比较大的文件,比较慢(不是因为家里网速限制,而往往是服务器这边的限制)

有一些多线程下载器,把一个大的文件,拆分成几个小的部分,使用多个线程下载

每个线程负责下载一部分,每个线程分别是一个网络连接

可以大幅度提高下载速度

当需要把一个任务拆成多个任务来完成,如何衡量现在是否把多个任务都搞定了呢?

这个时候就可以借助 CountDownLatch 

import java.util.concurrent.CountDownLatch;

//CountDownLatch
public class Demo29 {
    public static void main(String[] args) throws InterruptedException {
        //构造方法中,指定要创建几个任务
        CountDownLatch countDownLach = new CountDownLatch(10);
        for(int i = 0;i < 10;i ++){
            int id = i;
            Thread t = new Thread(() ->{
                System.out.println("线程" + id + " 开始工作");
                try {
                    //使用 sleep 代指某些耗时操作
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("线程" + id + " 结束工作");
                //在每个任务结束这里,调用一下方法
                //把 10 个线程想象成短跑比赛的 10 个运动员,countDown 就是运动员撞线了
                countDownLach.countDown();
            });
            t.start();
        }

        //主线程如何知道上述所有任务是否都完成了呢?
        //难道要在主线程中调用 10 次 join 吗?
        //万一要是这个任务结束,但是线程不需要结束,join 不也就不行了吗
        //主线程中可以使用 countDownLatch 负责等待任务结束
        // a -> all,等待所有任务结束,当调用 countDown 次数 < 初始设置的次数,await 就会阻塞
        countDownLach.await();
        System.out.println("多个线程的所有任务都执行完毕");
    }
}

十一、集合类

集合类,哪些是线程安全的?

Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.
这三个在关键的方法中使用了 synchronized ,所以认为它们是线程安全的
Vector 和 HashTable 都是在 java早期搞出来的集合类
加了锁,不一定就线程安全,不加锁也不一定就线程不安全, 判断一个代码是否线程安全,一定要具体问题具体分析
虽然 HashTable 的get 和 set 方法加了 synchronized ,但是如果不能正确使用,也可能会出现线程安全问题
(1)如果是多个线程,并发执行 set 操作,由于 synchronized 限制,是线程安全的
(2)如果多个线程进行一些更复杂的操作,比如判定 get  的值是 xxx ,再进行 set ,这样的操作,就未必是线程安全的
所以,Vecdtor 以及 Hashtable 这样的集合类,虽然加了 synchronized  也不能保证一定是线程安全的,同时,在单线程情况下,又可能因为 synchronized 影响到执行效率(锁消除只能一定程度降低影响,但是不是 100%)

(1)多环境使用 ArrayList

1、自己使用同步机制 (synchronized 或者 ReentrantLock)
前面做过很多相关的讨论了. 此处不再展开.
2、Collections.synchronizedList(new ArrayList);
 Collections.synchronizedList(new ArrayList);

ArrayList 本身没有使用 synchronized

但是你又不想自己加锁,就可以使用上面这个东西

能让 ArrayList 像 Vector 一样工作(很少使用)

3、使用 CopyOnWriteArrayList
写时复制
多线程同时修改同一个变量,是不安全的,那么如果多线程同时修改不同变量,是不是就安全了呢?

如果多线程取读取,本身就不会又任何线程安全问题

一旦有线程去修改,就会把自身复制一份 

尤其是修改如果比较耗时的话,其它线程还是从旧的数据上读取

一旦修改完成,就使用新的 ArrayList 去替换旧的 ArrayList (本质上就是一个引用的重新赋值,速度极快,并且又是原子的)

这个过程中,没有引入任何的加锁操作

使用了 创建副本 --> 修改副本 --> 使用副本替换 这样的流程来完成的线程安全的修改


(2)多线程环境使用队列

1) ArrayBlockingQueue 基于数组实现的阻塞队列
2) LinkedBlockingQueue 基于链表实现的阻塞队列
3) PriorityBlockingQueue 基于堆实现的带优先级的阻塞队列
4) TransferQueue 最多只包含一个元素的阻塞队列

(3)多线程环境使用哈希表

(a)Hashtable

HashMap 是线程不安全的

HashTable 是线程安全的,关键方法都提供了 synchronized

ConcurrentHashMap 是线程安全的 hash 表
HashTable 是直接在方法上加上 synchronized ,就相当于针对 this 加锁

任意针对 ht 对象的读操作,都会涉及到针对 this 的加锁

此时,如果有很多线程都想操作 ht,就一定会触发激烈的锁竞争,这些线程最后都只能一个一个排
着队,依次执行(并发程度很低)

 hash 表中,是将 key 经过 hash函数的计算,最终得到数组下标,当 key1 和 key2 最终得到的数

组下标相同的时候,就发生了 hash 冲突
二次探测的方式,一般真正的hash 表的使用中不会出现,所以我们使用链表的方式来解决这个哈希冲突
只要控制好链表长度不要太长,此时时间复杂度仍然是O(1)
如果两个修改操作,是针对不同的两个链表进行修改,是否存在线程安全问题呢?
很明显不会!!
如果正好是当前这个插入操作触发了扩容,是有可能有影响的,扩容操作是及其重量的操作,相当
于是把整个哈希表给重新搬运了一遍,相比之下,锁的开销就微乎其微了
既然单纯的针对两个链表进行修改操作没有线程安全问题,那这个锁是不是就没有必要加了呢?

具体的做法,就是给每个链表都安排一把锁

一个 hash 表上面的链表个数很多,两个线程正好在同时操作同一个链表本身这个概率就是非常低

的,整体锁的开销就大大降低了

由于 synchronized 随便拿个对象都可以用来加锁,就可以简单的使用每个链表的头节点,作为锁对象


(b)ConcurretnhashMap

ConcurrentHashMap的改进:

1、减小了锁的粒度,每个链表有一把锁,大部分情况下都不会涉及到锁冲突【核心】

2、广泛使用了 CAS 操作,会同时进行 size++,像这样的操作也不会产生锁冲突

3、写操作进行了链表级的加锁,读操作没有加锁

4、针对扩容操作进行了优化:渐进式扩容

HashTable 一旦触发扩容,就会立即的一口气的完成所有元素的搬运,这个过程相当耗时

渐进式扩容:化整为零,当需要进行扩容的时候,会创建出另一个更大的数组,然后把旧的数组上的数据逐渐的往新的数组上搬运,会出现一段时间,旧的数组和新的数组同时存在

1、新增元素,往新数组上插入

2、删除元素,把旧数组元素删掉即可

3、查找元素,新数组和旧数组都得查找

4、修改元素,统一把这个元素给搞到新数组上

与此同时,每个操作都会触发一定程度的搬运,每次搬运一点,就可以保证整体的时间不是很长,积少成多之后,逐渐完成搬运了,也就可以把之前的旧数组彻底销毁了

java8 之前,ConcurrentHashMap 是使用分段锁,从 Java8 开始,就是每个链表一把锁了

分段锁能提高效率,但是不如每个链表一把锁,代码实现起来也更加复杂 

猜你喜欢

转载自blog.csdn.net/weixin_73616913/article/details/132087954