多线程详解

文章目录

前言

我们知道在 JAVA 中实现并发编程的,大多是使用多线程。而在 JAVA 的标准库中就提供了一个 Thread 类,来表示/操作线程。创建好的 Thread 实例其实和操作系统中的线程就是一一对应的关系。

Thread 类其实就是 java 对操作系统提供的一组关于线程的 API(C语言风格)进行封装后的结果。

1.创建多线程的方式?

1. 通过继承(extends)Thread 类的方式来实现,同时重写 run 方法。

/**
 * 通过使一个类继承Thread,并重写run方法来实现
 */
class Rabbit extends Thread{
    @Override
    public void run() {
        //这个里面就写 Rabbit 线程的业务逻辑
        int count = 0;
        while(count < 20){
            System.out.println("rabbit跳出了第 "+count+" 步》》");
            count++;
        }
    }
}

创建并启动线程

public static void main(String[] args) {
   Thread rabbit = new Rabbit();
   rabbit.start();               //创建并启动 rabbit 线程

   //注意这时,这里有两个线程 main 和 rabbit
}

2. 通过实现 Runnable 接口来创建多线程

/**
 * 让一个类实现 Runnable 接口,并重写 run 方法来实现
 */
class Dog implements Runnable{
    @Override
    public void run() {
        //dog线程的业务逻辑
        int count = 0;
        while(count < 20){
            System.out.println("dog叫出了第 "+count+" 声》》");
            count++;
        }
    }
}

创建并启动线程

public static void main(String[] args) {
   Dog dog1 = new Dog();            //先创建Dog实例
   Thread dog = new Thread(dog1);   //然后将Dog实例传给Thread创建一个实例

   dog.start();   //创建并启动线程

}

3. 通过匿名内部类的方式来创建

/**
* 使用匿名内部类的方式来创建多线程
* 写法一:
*/
public static void main(String[] args) {
    Thread bird = new Thread(new Runnable() { 
  //相当于创建了一个匿名内部类实现了Runnable,然后new出了实例,并将实例传给了 Thread,也重写了run方法
    	@Override
    	public void run() {
        	System.out.println("小鸟在飞...");
    	}
    });
    bird.start(); //创建并启动多线程
}
public static void main(String[] args) {
    /**
	* 使用匿名内部类的方式来创建多线程
	* 写法二:
    */
    Thread bird = new Thread(){
    //相当于创建了一个匿名内部类,继承了 Thread 类,并重写了run方法
        @Override
        public void run() {
            System.out.println("另一只小鸟也在飞...");
        }
    };
    bird.start();
}

相比于写法二,写法一这种实现 Runnable 的方式更好一点,有利于线程与线程执行的任务进行解耦。

为什么?因为 Runnable 只是描述了一个任务,至于这个任务到底是谁来执行,它本身及自身里面的代码并不关心!!!

将写法一使用 Lambda 表达式的写法

public static void main(String[] args) {
    Thread t = new Thread(()->{
        System.out.println("这是一个使用lambda表达式的创建多线程的匿名内部类写法...");
    });
    t.start();
}

当然,如果run方法里只有一条语句,为了简洁也是可以将{}去掉的**(最好不要使用这种代码,可读性极差)**

public static void main(String[] args) {
    Thread t = new Thread(()->System.out.println("这是一个使用lambda表达式的创建多线程的匿名内部类写法..."));
    t.start();
}

2. 举例说明多线程是随机唤醒的

/**
 * 模拟实现吃早餐
 */
public class TestThread2 {
    public static void main(String[] args) {
        //吃包子线程
        Thread eat1 = new Thread(()-> {
            while(true){
                System.out.println("早餐吃 包子 ...");
                //为了能更好的看到现象,这里加上一个 sleep
                try {
                    Thread.sleep(2000);  
            //这里的意义不是2秒后上CPU,而是2秒内该线程上不了CPU,至于什么时候上CPU,我们并不能够知道
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        eat1.start();

        //吃油条线程
        Thread eat2 = new Thread(()-> {
            while(true){
                System.out.println("早餐吃 油条 ...");
                //为了能更好的看到现象,这里加上一个 sleep
                try {
                    Thread.sleep(2000);  
             //这里的意义不是2秒后上CPU,而是2秒内该线程上不了CPU,至于什么时候上CPU,我们并不能够知道
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        eat2.start();
    }
}

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

3. 使用多线程的效率有多高?

举例说明:让两个变量各自自增 10 亿次,分别比较 单线程 和 多线程 的执行时间。

public class TestThread3 {
    /**
     * 使用多线程和单线程比较执行自增10亿次的时间
     */
    public static long N = 10_0000_0000L;

    /**
     * 使用多线程,分别自增10亿次
     */
    public static void MyThread() throws InterruptedException {
        //记录开始时的时间
        long start = System.currentTimeMillis();
        //创建一个线程自增10亿次
        Thread t1 = new Thread(()->{
            int count = 0;
            while(count < N){
                count++;
            }
        });
        //创建另一线程自增10亿次
        Thread t2 = new Thread(()->{
            int count = 0;
            while(count < N){
                count++;
            }
        });

        //启动两个线程
        t1.start();
        t2.start();
        //在这里等待 t1 和 t2 线程执行完毕
        t1.join();
        t2.join();
        //记录结束时间
        long end = System.currentTimeMillis();
        //计算时间差
        System.out.println("消耗时间:"+(end-start)+"ms");
    }

    //使用单线程分别自增10亿次
    public static void singleThread(){
        //记录开始时的时间
        long start = System.currentTimeMillis();
        int count = 0;
        while(count < N){
            count++;
        }
        count = 0;
        while(count < N){
            count++;
        }
        //记录结束时间
        long end = System.currentTimeMillis();
        //计算时间差
        System.out.println("消耗时间:"+(end-start)+"ms");
    }

    public static void main(String[] args) throws InterruptedException {
//        MyThread();      //使用多线程
        singleThread();    //使用单线程
    }
}

单线程 时的运行结果:
在这里插入图片描述

多线程 时的运行结果:

通过结果图的对比,我们发现在多线程下,所执行的时间比单线程块很多。

并且我们还会发现一个规律,那就是,执行次数越大,多线程所提高的效率也就越高

4. Thread 类中一些其他的方法和属性

1. Thread(String name)

这个构造方法,主要就是创建一个线程对象,并给创建的线程对象命名。这个的主要用途就是为了方便程序员去调试和观察。

public class TestThread4 {
    public static void main(String[] args) {
        Thread t = new Thread("线程一"){   //给这个线程去了个名字
            @Override
            public void run() {
                System.out.println(this.getName()+"正在执行..."); 
                //getName():获取线程的名字,在属性中会进行介绍
            }
        };
        t.start();
    }
}

运行结果:

在这里插入图片描述

2. Thread(Runnable target, String name)

这个构造方法和我们上面介绍创建多线程时通过实现Runnable接口的方式来创建多线程的写法很类似,就只是多了一个参数 String name,线程名。

public class TestThread4 {

    public static void main(String[] args) {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName()+"正在执行...");
                //这时就不能使用 this 了,原因在下面解答。这里不过多介绍。
            }
        },"线程二");
        t.start();
    }
}

运行结果:

在这里插入图片描述

3.Thread.currentThread() 与 this 的区别

this :当前调用它的对象的引用。

Thread.currentThread() :获取当前正在运行的线程对象的引用。

使用范围:

this : 只能使用在通过继承Thread类创建的多线程的情况下。

Thread.currentThread():不论是继承 Thread类 创建的多线程,还是实现 Runnable 接口创建的多线程,都能使用。

举例:

public class TestThread7 {
    public static void main(String[] args) {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("当前线程的名字:"+Thread.currentThread().getName());
         //System.out.println("当前线程的名字:"+this.getName()); //报错,没有getName()这个方法
            }
        },"实现Runnable接口创建的多线程");  //给这个线程取个名字,在上面已经介绍过该构造方法。
        t.start();
    }
}

运行结果:

在这里插入图片描述

public class TestThread7 {

    public static void main(String[] args) {
        Thread t = new Thread("继承Thread类创建的多线程"){   //也给这个线程取个名字
            @Override
            public void run() {
                //这时两种写法都可以
                System.out.println("当前线程的名字:"+Thread.currentThread().getName());
                System.out.println("当前线程的名字:"+this.getName());
            }
        };
        t.start();
    }
}

运行结果:

在这里插入图片描述

分析 Thread.currentThread() 与 this 使用范围的原因:

我们通过上面的两个例子知道了 this 的范围比 Thread.currentThread() 要小一些,并且小的范围就是实现(implements) Runnable 接口时的情况。为什么?

在上面我们说了,要想使用 Runnable 接口的方式创建多线程,那么我们**需要有一个类(普通类/匿名内部类)实现Runnable接口,然后实例化这个类,再将实例化的类传递给Thread类进行构造。**那么 getName() 这个方法到底是在哪个类中的,我们通过查看JDK源码,发现只有 Thread 类中才有这个方法。到这里问题就解决了,子类继承了父类后,在子类里就可以使用父类中的非私有方法。故 this 只能使用在 继承 Thread 类的情况下。

4. 常见的几个属性

属性获取方法描述
IDgetId()相当于线程的身份证号,不同的线程不会重复
名称getName()线程的名称,主要在调试时用到
状态getState()状态表示当前线程所处的一个情况
优先级getPriority()表示该线程被调度的可能性
是否是后台线程isDaemon()默认是前台线程,后台线程不会影响进程的退出
是否存活isAlive()表示当前线程的运行情况
是否被中断isInterrupted()查看当前线程是否被中断了

5. start()

这是多线程中重要的方法之一,这个方法决定了当前线程是否真的被创建出来,并开始运行起来。

1. 区分 run() 和 start()

public class TestThread5 {
    public static void main(String[] args){
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                //t线程业务逻辑:循环打印 hello run()...
                while(true){
                    System.out.println("hello run()...");
                    try {
                        //防止打印太快,加个sleep
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });

        //调用t中run方法,用来对比结果
        t.run();
        //t.start();
        //这里是main线程执行的逻辑:循环打印 hello main()... 也加个sleep
        while(true){
            System.out.println("hello main()...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

运行结果:

然后将 t.run() 注释掉,将 t.start() 放开,再次运行程序:

在这里插入图片描述

对比使用 run 方法和 start 方法的运行结果后,我们发现调用 run 方法出现的情况就和我们调用普通方法时出现的情况一样,说明这时并没有创建新的线程,从始至终都只有main线程在调用run()方法,也就是单线程状况。只有 t.run() 执行完毕后,才会执行后面的循环打印 hello main()…。

而第二张使用 t.start() 的图片上是交错执行的,说明这时是两个线程在运行,线程 t 和 线程 main 。它们在互相抢夺时间资源,你执行一下,我执行一下。也就是多线程状况。

总结: run() 方法相当于只是描述了一下线程运行时需要执行的业务逻辑,而 start() 方法则是决定系统是否在内存中创建线程,并且执行起来。

6. 中断线程

为什么需要中断线程?

举个例子:我正在家里打游戏,这就相当于一个正在执行的线程。突然我接到了父母的电话,叫我给他们开个门。这时我就需要停止打游戏,然后去开门。这里的操作就类似于中断当前正在执行的线程,然后去干另一件事,也就是去执行另一个线程。

1. 使用自定义标志位来中断线程。

public class TestThread6 {

    //通过自定义标志位来实现线程的中断
    public static boolean isTrue = false;

    public static void main(String[] args) {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                while(!isTrue){
                    System.out.println("hello Thread...");
                    //来个sleep,方便我们观察效果
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        //在内存中创建并启动线程
        t.start();

        //5秒之内不会对 标志位(isTrue) 进程更改
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //更改标志位
        isTrue = true;
        System.out.println("main线程以执行,标志位已更改!!!");
    }
}

运行结果:

在这里插入图片描述

通过运行结果我们可以看到程序确实是在5秒之后才执行main线程,并更改了标志位,然后t线程也就停止了。

2. 使用 Thread 中的内置标志位来实现

这里Thread中给我们提供了三个方法:

方法描述
public void interrupt()调用这个方法后,可以设置标志位(没有阻塞时),反之则会抛异常
public static boolean interrupted()这是一个静态方法,多个线程会使用同一个标志位,故不推荐使用
public boolean isInterrupted()判断对象线程的标志位是否设置,调用后不会清除标志位

举例:

public class TestThread8 {
    //使用Thread类中自带的标志位来完成线程的中断
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            //判断是否是中断状态
            while(!Thread.currentThread().isInterrupted()){
                System.out.println(Thread.currentThread().getName()+"...");
                //避免打印过快,这里需要加上一个sleep
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"线程A");
        t.start();

        //5秒之内 main 线程后面的内容上不了CPU
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //设置标志位,进行中断
        t.interrupt();
    }
}

运行结果:

在这里插入图片描述

通过运行结果我们可以明显的看到出了问题!!!

最开始线程A执行了5次,然后main线程开始执行,并给线程A设置了标记位。然后当线程A再次执行的时候,就抛出了一个异常:java.lang.InterruptedException: sleep interrupted

而这个异常就是在多线程中最常见的异常,表示当前线程被强制中断了,并在这里给出了信息:是被 sleep 给强制中断的。然而令人费解的是这里在抛出中断异常之后,线程A 居然没有被中断,而是继续的执行着…

通过分析:我们发现这里出现了两个问题:

  • 这里抛出了一个异常
  • 抛出异常之后线程A居然没有停下来

既然这样,我们接下来就把这个 sleep 去掉运行看看:

public class TestThread8 {
    //使用Thread类中自带的标志位来完成线程的中断
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            //判断是否是中断状态
            while(!Thread.currentThread().isInterrupted()){
                System.out.println(Thread.currentThread().getName()+"...");
            }
        },"线程A");
        t.start();

        //由于我们把上面的sleep给去掉了,所以这里就给小一些,方便查看结果
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //设置标志位,进行中断
        t.interrupt();
    }
}

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

惊奇的发现,两个问题都解决了!!!

通过上面的分析我们知道了在使用 interrupt() 时会有两种情况:

  • 如果当前线程是处在就绪状态时,调用interrupt(),则会将标志位设置为true。
  • 如果当前线程是处在阻塞状态时,调用interrupt(),则会抛出一个异常(InterruptedException),但是没有设置标志位。所以该线程还是会继续执行。

怎么解决第二种情况下,线程还是会继续执行的情况???

我们可以添加 break,通过跳出循环来提前结束 run 方法。

public class TestThread8 {
    //使用Thread类中自带的标志位来完成线程的中断
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            //判断是否是中断状态
            while(!Thread.currentThread().isInterrupted()){
                System.out.println(Thread.currentThread().getName()+"...");
                //避免打印过快,这里需要加上一个sleep
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
  //这时如果触发 InterruptedException 异常,则先打印一下栈轨迹,然后进行收尾工作,最后提前结束 run 方法
                    e.printStackTrace();
                    System.out.println("处理收尾工作...");
                    break;
                }
            }
        },"线程A");
        t.start();

        //5秒之内 main 线程后面的内容上不了CPU
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //设置标志位,进行中断
        t.interrupt();
    }
}

运行结果:

在这里插入图片描述

这时程序结束了,也进行了收尾工作。

7.线程等待

我们知道在操作系统上的线程是一种抢占式执行,随机性调度的模式,但是这种模式并不好,所有我们就需要来控制下线程之间的顺序。

(注意:这里所说的顺序,是指线程执行结束的顺序。调度顺序是操作系统随机的,我们干预不了!!!)

1. join()

在调用 join() 方法时,哪个线程调用的 join() ,哪个线程就会进行阻塞等待,必须要对应的线程完全执行完毕才行。在这个过程中调用 join() 的线程就会**“死等”**。

我们在上面第3大点中举例说明多线程的效率时,就有使用到 join() 方法

这里再举一个简单的例子:

public class TestThread9 {
    public static void main(String[] args) throws InterruptedException {
        //第一线程
        Thread t1 = new Thread(()->{
            for(int i = 1; i <= 5;i++){
               System.out.println(Thread.currentThread().getName()+" 执行到了第 "+i+" 次");
            }
        },"Thread-1");

        //第二个线程
        Thread t2 = new Thread(()->{
            for(int i = 1; i <= 5;i++){
               System.out.println(Thread.currentThread().getName()+" 执行到了第 "+i+" 次");
            }
        },"Thread-2");

        //在内存中创建并启动t1和t2线程
        t1.start();
        t2.start();

        //让main线程进行等待,等待t1和t2执行完毕
        t1.join();
        t2.join();

        //main线程所要执行的操作
        System.out.println("这里是main线程的工作...");
    }
}

运行结果:

只有在 Thread-1 和 Thread-2 线程都执行完毕后,main 线程后面的代码才执行。

2. join(等待的毫秒数)

相比于 join() 方法,这个方法多了一个参数(需要等待的时间)。与 join() 最大的不同便是它并不会进行 “死等”,而是在等待一段时间之后,就从阻塞状态重写回到了就绪状态,等待操作系统的调度。

public class TestThread10 {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            int count = 0;
            while(!Thread.currentThread().isInterrupted()){
           //打印一下是哪个线程执行的第几次,方便观察结果
           System.out.println(Thread.currentThread().getName()+" 正在执行第 "+count+" 次");
                count++;
                try {
                    //增加打印的时间间隔,同样是为了方便观察
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"Thread - 1");

        Thread t2 = new Thread(()->{
            int count = 0;
            while(!Thread.currentThread().isInterrupted()){
           //打印一下是哪个线程执行的第几次,方便观察结果
           System.out.println(Thread.currentThread().getName()+" 正在执行第 "+count+" 次");
                count++;
                try {
                    //增加打印的时间间隔,同样是为了方便观察
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"Thread - 2");
        
        //在内存中创建并启动线程
        t1.start();
        t2.start();
        //main线程调用 join 方法,等待 t1 和 t2 执行完毕,但是最多等3秒
        t1.join(3000);
        t2.join(3000);
        
        //main线程的内容
        int count = 0;
        while(true){
            System.out.println("main线程开始执行了第 "+ count+" 次");
            count++;
            Thread.sleep(1000);
        }
    }
}

运行结果:

在这里插入图片描述

通过运行结果,我们可以看到在最开始的时候,main 线程等待了 Thread - 1 和 Thread - 2 一段时间,但当等待的时间过了的时候,main 线程也就开始抢占CPU时间资源了。

至于为什么线程没结束,是因为我这里使用的是 while(true)

8. 线程的休眠

1. sleep(休眠的毫秒数)

想要一个线程进入休眠,我们就可以使用 sleep() 方法,这个方法我们在上面也多次用到。

通俗的讲就是使一个线程在一定时间范围内上不了CPU,也就是使该线程在一定时间内处于阻塞状态。

public static void main(String[] args) {
    Thread t = new Thread(()->{
        int count = 0;
        while(count < 100){
            System.out.println("hello world>>>>");
            //每打印一次,就休眠一段时间
            try {
                 Thread.sleep(1000);
            } catch (InterruptedException e) {
                 e.printStackTrace();
            }
        }
    });
    //在内存中创建并启动线程
    t.start();
}

2. 理解线程休眠的内部原理(Linux)

我们知道操作系统管理进程(这里默认的是一个进程里只有一个线程)是通过 描述+组织 的方式来完成的。

在以下这篇文章中有较详细的讲解:

https://blog.csdn.net/m0_52066789/article/details/124675383

而在描述时,在Linux下是通过 PCB(进程控制块) 来完成的,也就是一个进程对应一个PCB,当然这里说法有点不同,因为在 Linux 下是没有 线程 这一说法的,所以线程在Linux下被称为轻量级进程。但在口语化中,我们还是将进程和线程区分开来了。所以上面这句话更准确的说法应该是一个线程对应一个PCB。然后在PCB中存放着每个线程的信息。

在Linux下,组织则是通过使用双向链表来将一个一个的PCB给串起来。这个双向链表就可以称为一条队列。

1. tgroupId

在 PCB 中有一个字段,叫 pid(PCB的id)。同时其中也还有一个 tgroupId ,那这个是干嘛的呢?

我们上面说了,在 Linux 中是不区分 进程 和 线程 的,它只认 PCB ,所以我们可以这样理解,tgroupId 的值相同就表示这些 PCB 公用同一块内存空间和资源。也就是说这些 PCB 在同一个进程中。而 PID 则是每个 PCB 的身份标识。

2. 简要说明线程调用sleep方法时的具体情况

假如有下图两条队列**(在系统中这种双向链表的队列有许多,这里只是举个例子)**:

在这里插入图片描述

然后 pid 为 100 的线程调用 sleep() 后就变成了以下状况:

在这里插入图片描述

同理,当睡眠的时间到了,就又会被操作系统挪回就绪队列中。

同时注意:操作系统在调度线程的时候,只会从就绪队列中选择PCB,而阻塞队列中的PCB只能干等着

9 线程的状态

在java中的 Thread 类中,将线程的各种状态有进行了细分:

由于在java中线程状态的存放是一个枚举,所以我们可以将它们打印出来。

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

运行结果:

在这里插入图片描述

1. NEW

这个状态表示线程所需要完成的工作已经布置完毕,但是还没有启动,也就是没有调用 start() 方法。

2. RUNNABLE

这个状态表示当前线程正处于就绪状态,随时都可能调度上CPU。

3 TERMINATED

这个状态表示当前线程所需要执行的操作已经完成,但是Thread对象还在,内存中的线程已经没了。

举例:

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            System.out.println("线程一...");
        });
        System.out.println(t.getState());    //获取t线程还没启动时的状态

        t.start();                           //在内存中真正创建线程并启动
        System.out.println(t.getState());    //在此查看t线程的状态

        t.join();                            //main线程等待t线程执行完毕
        System.out.println(t.getState());    //然后获取当前t的状态
    }

4. BLOCKED

这个比较好理解,表示当前线程是由于等待锁而处于阻塞状态。

5. WAITING

这个也是一个阻塞状态,但它是由于 wait() 而处于阻塞的。需要等待被唤醒。这个是死等的,如果没有线程去调用notify()来唤醒它。

6. TIMED_WAITING

同样也是一个阻塞状态,但它是由于sleep()而处于阻塞的。它的等待是有一个时间限制的。也就是不会死等。

注意:JDK自带的有一个小工具,jconsole,它可以查看当前java运行的线程的状态。

在这里插入图片描述

以上是一个简单的状态转换图。

10 线程安全问题(重点)

线程安全问题是在多线程的模式下所出现的问题。这是由于操作系统在调度线程的时候是随机性调度,抢占式执行的而这样的随机性就有可能给程序的运行带来一些BUG,这时就说明是线程不安全的!!!

一个典型案例:

使用两个线程对同一个整形变量自增50000次,然后查看最后的结果

public class TestDemo {
    private static int count = 0;
    public static void add(){
        count++;
    }

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

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

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

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

        System.out.println(count);
    }
}

多次运行上诉的代码,发现结果并不都是100000。所以这个程序是线程不安全的!!!

为什么?

答案:count++ 这个操作并不是原子的。实际上它需要分成三个指令来执行:load 、 add 和 save。

load : 将内存中的值加载到寄存器中。

add : 将寄存器中的值加 1。

save : 将寄存器的值写到内存中。

由于操作不是原子的,所以当 t1 线程执行完 load 指令后时间片段没了。这时 t2 线程开始执行 load 、add 和 save指令。执行完毕 count 加了 1 。t1线程又来执行,而它是从 add 开始执行,所以它拿到的值还是原先的count值,所以 t1 执行完毕后,count 的大小并没有变。所以就出现了这样一种情况:

t1 线程和 t2 线程都执行了一遍,但是 count 的值却只加了 1

解决办法:

将count这个操作通过使用 synchronized 锁将 它打包成一个整体。

public class TestDemo {
    private static int count = 0;
    synchronized public static void add(){
        count++;
    }

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

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

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

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

        System.out.println(count);
    }
}

这时我们运行程序,便发现它的结果始终是 100000。

1. 造成线程不安全的原因

  • 线程是抢占式执行,调度的时候也是随机性调度。(根本原因,无法从代码的层次解决)
  • 多个线程对同一个变量进行修改操作。

(这时我们可以适当的调整代码结构,使多个线程修改不同的变量。)

  • 针对变量的操作不是原子的。

(比如++操作,他其实是三个指令。解决办法是通过加锁的方式,将其打包成原子的。)

  • 内存可见性的问题也是会造成线程不安全。

(产生内存可见性问题主要是由于编译器的优化。)

  • 指令重排序

(也是编译器优化产生的。编译器为了提高程序的效率,所以对指令执行的顺序进行了优化。)

2. 内存可见性

出现这个问题的场景:

一个线程读,一个线程写的情况下。

举个例子:

public class TestDemo {

    public static int isFlag = 0;

    //内存可见性问题
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            //t线程一直读取isFlag的值,然后与0进行判断
            while(isFlag == 0){

            }
            //走到这里,说明t线程感知到了isFlag的变化,t线程结束
            System.out.println("t线程结束了...");
        });
        //开启t线程
        t.start();
		
        //通过main线程来修改isFlag的值,观察t线程能否感知到
        Scanner scanner = new Scanner(System.in);
        System.out.println("请修改isFlag的值:");
        isFlag = scanner.nextInt();
        System.out.println("isFlag的值已经被修改...");
    }
}

将程序运行起来,稍等一会儿,等 t 线程运行一段时间后,我们通过控制台修改 isFlag 的值。这时确发现在控制台只打印了 “isFlag的值已经被修改…”,而没有打印 “t 线程结束了…”。并且程序并没有停止下来。

这种现象就是内存不可见问题。当程序运行起来后,CPU 在很长的一段时间里发现从内存中拿到的 isFlag 值都为 0 ,且并没有变化。所以为了提高效率,编译器就进行了优化,让CPU直接从寄存器中拿上一次的值来进行判断。而不再从内存中取值。

这时,如果又来了一个线程,并且它将内存中 isFlag 值给修改了。但是由于读的线程还是在从寄存器中拿上一次的值,所以它感知不到内存中的 isFlag 值的变化。最终产生了这种情况!!!

解决办法:

通过使用 volatile 关键字让编译器不对它进行优化。

public class TestDemo {
    //使用 volatile 关键字解决内存可见性问题(不能解决原子性问题)
    public volatile static int isFlag = 0;

    //内存可见性问题
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            while(isFlag == 0){

            }
            System.out.println("t线程结束了...");
        });
        //开启t线程
        t.start();

        Scanner scanner = new Scanner(System.in);
        System.out.println("请修改isFlag的值:");
        isFlag = scanner.nextInt();
        System.out.println("isFlag的值已经被修改...");
    }
}

运行起来后发现,只要一从控制台修改 isFlag 的值,t 线程就马上执行完了。

3. 指令重排序

这个问题同样是由于编译器优化产生的。编译器在不改变原来代码逻辑的基础上,为了提高效率,而对指令执行的顺序进行了调整。

在单线程的情况下,编译器的判断都是很准的,也就是不会出现问题。但是在多线程的情况下,编译器的判断并不一定准。它在优化的过程中也就很容易使原程序出现BUG。从而产生线程不安全问题。

解决办法:使用 volatile 关键字就可以解决。

4. synchronized

这个关键字我们在上面知道了它可以解决原子性问题、内存可见性问题。

而使用 synchronized 是需要指定 锁对象 的,在java中任何一个new出的实例,它的里面都有一个对象头一些对象的元数据 ,这个我们程序员用不到,但是 JVM 需要用到。

指定锁对象就是在对象的对象头里设置标志位。

1. 修饰普通方法

当 synchronized 修饰普通方法时,这时的 锁对象 就指定为了 this

public class demo {
    //当前的锁对象就是 this
    synchronized public void func(){

    }
}

2.修饰静态方法

当其修饰静态方法时,锁对象 就是当前的 类对象 ,即 类名.class

public class demo {
    //当前的锁对象就是 demo.class
    synchronized public static void func(){
        
    }
}

3. 修饰代码块

这时就有了一些变化,上面的锁对象都没有显示出来,而修饰代码块时是需要我们手动指定的。

public class demo {
    public static void main(String[] args) {
        //修饰demo类对象
        synchronized (demo.class){

        }
    }
    public void func(){
        //修饰当前demo实例对象
        synchronized (this){

        }
    }
}

5 .monitor lock

monitor lock 是一个监视器锁,synchronized 就是依靠它来记录,并且保证锁的状态。也就是控制synchronized什么时候获取锁,什么时候释放锁 和 记录锁被重入的次数。(就是对一个对象重复加锁的次数)

11. 可重入锁

定义:如果同一个线程对同一把锁连续加锁多次后,出现死锁情况,就称为不可重入锁。反之则称为可重入锁。

public class demo {

    public int count = 0;
    //使用 synchronized 两次加锁
    synchronized public void func(){
        synchronized (this){
            count++;
        }
    }
    public static void main(String[] args) {
        demo demo = new demo();
        demo.func();
        
        System.out.println(demo.count);
    }
}

运行结果发现程序并没有卡死,而是正常停止了。并且还得到了正确的结果。

所以 synchronized 是可重入锁

可重入锁是怎样实现的?

在可重入锁的内部会记录当前的锁是被哪个线程占用的,同时也会记录加锁的次数。这样只有在第一次加锁时才是真正的上锁。后序的加锁操作都只是增加了记录加锁次数的值。

同时解锁也是一样的,只有当加锁次数的值为0时,才会真正解锁。

1. 出现死锁的一些情况

1. 一个线程,一把锁

这种情况就是上诉所举例的 count++ 。由于使用的是 synchronized 所以不会出现死锁。

逻辑情况:第一次上锁时正常,第二次上锁就需要第一次上的锁解锁。但第一次的锁想要解锁,就需要执行完 func() 中的内容,而想执行 func() 中的内容,就需要第二次上锁成功。


2. 两个线程,两把锁

举个例子:

  • 有两个人,甲和乙。它们一同去了一家饺子馆吃饺子。甲手里拿着醋,乙手里拿着酱油。两人吃到一半,甲对乙说:你把酱油给我,等我吃完,就把醋给你。乙对甲回道:你先把醋给我,等我吃完,再把酱油给你。

这时就出现了死锁!!!


3. n个线程,m把锁

这时的情况就比较复杂。

一个经典的例子:哲学家就餐问题

  • 假如有5个哲学家:ABCDE,他们围着一张桌子坐成了一个圆。他们每个人的左右两边各有一支筷子,面前有一碗面条。每个哲学家都会做两件事:1.思考 2.吃面条。 并且他们每个人做这两件事的时间都是随机的。同时他们有一个约定:吃面条必须先拿起左手边的筷子,然后拿起右手边的筷子才能吃面条。并且每个哲学家都很固执,当他们想吃面条时,如果发现筷子正在被其他人使用,他们就会一直等。直到吃上面条为止。
  • 当某一时刻,所有的哲学家都想吃面条了。他们同时拿起了左手边的筷子。然后准备拿起右手边的筷子时,发现筷子都在被他人使用。这时他们就会死等。同时由于所有的哲学家都进入了等待。就产生了环路等待。导致死锁。

在这里插入图片描述

怎么解决?

  1. 给筷子编个号。
  2. 哲学家们进行约定:每个人拿左右两边筷子中编号小的那一根。

修改后的结果:

在这里插入图片描述


总结:

死锁的必要条件:

  1. 互斥使用:当一个线程占用了一把锁后,别的线程也就占用不了该锁。
  2. 不可抢占:当一个线程占用了一把锁后,别的线程不能把锁抢走。
  3. 请求和保持:当一个线程占用了多把锁后,除非显式的释放锁,不然始终被该线程占用着。
  4. 环路等待:就是等待释放锁的关系形成了一个环。(最重要的一点)

(环路等待的解决办法:在多把锁加锁的时候,规定好固定的顺序即可)

12. JMM

JMM全称叫 Java Memory Model (java内存模型)

JMM到底是什么?

我们知道在计算机中有CPU和内存,而计算机如果想要进行一些计算,那么首先就会把数据从内存中读到CPU寄存器中,然后在寄存器中进行运算,最后写入到内存中。

JMM就是对以上的硬件用专业术语重新进行了封装。

CPU寄存器 —》 工作内存(work memory)

内存 —》主内存(main memory)

同时,由于CPU寄存器运行速度快、容量小的特点导致寄存器的空间太紧张了。而内存虽然容量大了,但速度太慢。所以在工作内存(work memory)中又出现了一个东西 — 缓存。

一般的计算机都有三级缓存: L1 、L2 、L3

它们与CPU寄存器和内存的关系:

运行速度:

CPU寄存器最快 —》 L1 其次 —》 L2再其次 —》 L3 再其次 —》 内存最慢

存储容量:

CPU寄存器最小 —》 L1 其次 —》 L2再其次 —》 L3 再其次 —》 内存最大

制造成本:

CPU寄存器最高 —》L1 其次 —》 L2再其次 —》 L3 再其次 —》 内存最低

举个例子:

CPU寄存器就好比衣服上的口袋。

L1缓存就好比手提包。

L2缓存就好比身后的背包。

L3缓存就好比行李箱。

内存就好比家。

总结:最常用的数据放到CPU寄存器中,然后依次常用的放到 L1、L2、L3中,最不常用的就放到内存中。

注意:在 JMM 中,缓存是和CPU寄存器统称为工作内存(work memory)

13. wait() 和 notify() / notifyAll()

由于线程的调度是随机的,但我们往往并不希望有这种不确定性。这时我们就可以通过 wait() 和 notify() 来使线程的调度有一个固定的顺序。

1. wait() 和 notify()

哪个线程调度 wait() 哪个线程就会进行阻塞等待。直到有另一个线程通过 notify() 通知它。然后会继续执行。

wait所做的三件事:

  1. 先将锁释放。
  2. 调用 wait 的线程进行阻塞等待。
  3. 阻塞到其他线程使用 notify 来通知,重新获取所并继续执行。

由于 wait 需要先释放锁,所以它需要配和 synchronized 来使用。

举例:

class A implements Runnable{
    @Override
    public void run() {
        synchronized (this){
            System.out.println("wait开始了...");
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("wait结束了...");
        }
    }
}

public class demo {
    public static void main(String[] args) throws InterruptedException {
        //创建一个 A 实例
        A a = new A();
        Thread t = new Thread(a);
        t.start();

        //5秒后进行notify通知
        Thread.sleep(5000);
        synchronized (a){
            a.notify();
            System.out.println("main线程进行了通知...");
        }
    }
}

从运行结果中我们可以看到,t 线程执行到 wait开始… 就停止了。随后睡眠了 5 秒 。main 线程对 t 线程进行了通知。main线程的内容执行完毕之后,t线程又开始执行了。

注意:

wait() 和 notify() 的操作针对的是某一个对象。

比如:有10个线程针对某一对象同时进行了 wait() 操作。它们就都进入了等待。随后有一个线程针对这个对象 notify() 操作,这时等待的10个线程就会随机唤醒其中的某一个。

2. notifyAll()

这个和上面的 notify() 只有一个区别,就是notify() 唤醒一个,notifyAll() 唤醒全部。但 notify() 更常用,notifyAll() 的竞争太过激烈。

14. 实现线程安全的单例模式(重点)

线程安全我们在上面已经介绍过了:

指一段代码在多线程的模式下运行不会产生BUG,那就说这是线程安全的。

什么叫单例模式?

单例模式是一种设计模式,而设计模式是指在一些常见的情况下使用的一些经典解决方案。

单例模式通俗的讲就是在一段代码中的某一个类,它只有一个实例。也就是只能new一个实例对象。

什么场景下使用?

比如我们的JDBC代码中的设置数据源 DateSource 。在连接数据库时,我们只需要一份便可。

1. 饿汉模式

为什么叫饿汉模式?

  • 因为使用这种模式,就会在类加载的时候进行那个唯一对象的创建。通俗的讲就是不管你后面用不用到它,首先就给你创建号放到这里。后面你要用的时候就拿来用。
  • 举个例子:洗碗,一个人不管下一餐用到多少碗,他都会全部都洗了。这就是饿汉模式。

代码例子:

//单例模式 — 饿汉模式
public class Test {
    //在类加载的时候创建唯一的对象(静态字段是属于类的,只有一份)
    private static Test test= new Test();
    //将无参构造方法设置为私有的,不允许在类外调用
    private Test(){};
    //给一个公共的接口,用来在类外获取唯一的对象
    public static Test getTest(){
        return test;
    }

    public static void main(String[] args) {
        //拿到了那个单例对象
        Test test = Test.getTest();
    }
}

(线程安全)分析:我们看到由于在 getTest() 方法里并没有对变量进行修改操作,只有读操作。所以无论有多少线程来使用 getTest() 方法,它都是线程安全的。

2. 懒汉模式

  • 这个模式与饿汉模式的不同在于,饿汉模式是在类加载的时候进行了对象创建,而懒汉模式是在要用到的时候才进行创建一次。
  • 举个例子:还是洗碗,但这时不同,他并不会一下都给洗了,而是会进行判断需要几个碗,就洗几个碗。

代码例子:

//单例模式 — 懒汉模式
public class Test {
    //给一个属于类的字段存放对象的引用
    private static Test test = null;   //刚开始没有人要使用它
    //构造方法私有化,不让其在类外能实例化对象
    private Test(){};
    //给一个公共的接口,让其他线程能获取单利对象
    public static Test getTest(){
        //首先判断是否是第一次调用
        if(test == null){
            //创建对象
            test = new Test();
        }
        return test;
    }

    public static void main(String[] args) {
        //和饿汉模式一样的使用方法
        Test test = Test.getTest();
    }
}

(线程安全)分析:很明显我们能看到这个 getTest() 方法里面既有判断修改的操作,又有读的操作。并且判断修改的操作也不是原子的。所以很明显这是线程不安全的!!!

怎么解决?答:将判断修改的操作打包成原子的。

代码:

//单例模式 — 懒汉模式
public class Test {
    //给一个属于类的字段存放对象的引用
    private static  Test test = null;   //刚开始没有人要使用它
    //构造方法私有化,不让其在类外能实例化对象
    private Test(){};
    //给一个公共的接口,让其他线程能获取单利对象
    public static Test getTest(){
        synchronized(Test.class){ //因为这个方法是静态的,故可以直接指定锁对象为 Test.class
            //首先判断是否是第一次调用
            if(test == null){
                //创建对象
                test = new Test();
            }
        }
        return test;
    }

    public static void main(String[] args) {
        //和饿汉模式一样的使用方法
        Test test = Test.getTest();
    }
}

进一步分析:更改过后的代码与原代码的差别就是将判断修改操作给打包成了一个整体。所以线程安全问题解决了。但是我们仔细分析这个 getTest() 方法,便会发现只有第一次创建对象时才有线程安全问题。所以我们这样无脑加锁,就会降低代码的执行效率!!!

怎么提高效率? 答:再加一个条件来判断是否是第一次调用 getTest() 方法。

代码:

//单例模式 — 懒汉模式
public class Test {
    //给一个属于类的字段存放对象的引用
    private static Test test = null;   //刚开始没有人要使用它
    //构造方法私有化,不让其在类外能实例化对象
    private Test(){};
    //给一个公共的接口,让其他线程能获取单利对象
    public static Test getTest(){
        //判断是否是第一次调用
        if(test == null){
            synchronized(Test.class){ //因为这个方法是静态的,故可以直接指定锁对象为 Test.class
                //首先判断是否是第一次调用
                if(test == null){
                    //创建对象
                    test = new Test();
                }
            }
        }
        return test;
    }

    public static void main(String[] args) {
        //和饿汉模式一样的使用方法
        Test test = Test.getTest();
    }
}

分析:通过上面的改动,我们便将代码执行的效率大大提高了,只在第一次的时候才加锁并创建对象。但是又出现了一个问题,那就是在第一个 if 判断那里,可能会被编译器进行优化,从而出现内存可见性问题。同时在 test = new Test(); 这里也会出现指令重排序问题。但是由于在上面我们使用了 synchronized ,所以已经解决了内存可见性问题。故我们接下来就需要解决 指令重排序 这个问题了。

最终实现线程安全的单例模式 — 懒汉模式 的代码:

//单例模式 — 懒汉模式
public class Test {
    //给一个属于类的字段存放对象的引用,加上 volatile 关键字,解决指令重排序问题
    private volatile static Test test = null;   //刚开始没有人要使用它
    //构造方法私有化,不让其在类外能实例化对象
    private Test(){};
    //给一个公共的接口,让其他线程能获取单利对象
    public static Test getTest(){
        //判断是否是第一次调用
        if(test == null){
            //synchronized 在这里也解决了内存可见性问题
            synchronized(Test.class){ //因为这个方法是静态的,故可以直接指定锁对象为 Test.class
                //首先判断是否是第一次调用
                if(test == null){
                    //创建对象
                    test = new Test();
                }
            }
        }
        return test;
    }

    public static void main(String[] args) {
        //和饿汉模式一样的使用方法
        Test test = Test.getTest();
    }
}

15. 阻塞队列

阻塞队列也是符合普通队列的先进先出的规则。但它有两个特点:

  • 线程安全
  • 会进行阻塞等待

当队列为满时,进行入队操作,会阻塞等待,直到队列不为满。

当队列为空时,进行出队操作,会阻塞等待,直到队列不为空。

1. 生产者消费者模型

由于阻塞队列上述的特点,所有就可以实现 生产者消费者模型

生产者消费者模型在日常解决多线程问题中是非常常见的。

生产者消费者模型是啥?

举个例子:吃饺子…

  • 想要吃饺子就需要四个步骤:和面、擀饺子皮、包饺子、煮饺子。其中和面、煮饺子不好使用多人分工的方式来提高效率,但是我们可以处理擀饺子皮和包饺子这两个操作。当然又由于在普通的家庭中擀面杖一般只有一个。所以擀饺子皮这个操作只能由一个人来进行,他将擀好的饺子皮放到一个临时存放处。然后包饺子的人就只需要从这个临时存放处拿到饺子皮进行包。这里使包饺子由多个人完成。这样效率就大大提高了。
  • 在上面的例子中,负责擀饺子皮的人就相当于生产者。包饺子的人就是消费者。而临时存放饺子皮的工具就是交易场所,这个一般由阻塞队列来担当。

生产者消费者模型在多线程问题中应用的范围很广,特别是在服务器开发的场景中。

举个例子:

  • 有两个服务器,如果不使用生产者消费者模型,那么它们的关系就会如下图所示。这样的话,A服务器和B服务器的耦合性太高,如果有一天需要将B服务器换成C服务器,那么A服务器也会受到影响。而且如果在某一时刻内A服务器的请求量大增。比如春运。由于A服务器只是来接收请求的,并没有处理,所以它的压力自己顶一顶还是可以承担下来。但是B服务器由于需要处理每个请求,所以就很有可能将B服务器给弄挂了(如果B服务器的硬件设备跟不上。)。

在这里插入图片描述

2. 生产者消费者模型的优点之解耦更充分

在上面的例子中,我们发现如果使A服务器和B服务器直接交互,那么就会由于耦合性太高而产生一系列的问题。

所以我们使用生产者消费者模型来解决:

在这里插入图片描述

特点:

  • A服务器只需要关注与阻塞队列的交互,不需要认识B服务器。
  • B服务器也只需要关注与阻塞队列的交互,不需要认识A服务器。
  • A服务器感知不到B服务器,如果将B服务器换成C服务器,对于A服务器来说也没有任何的变化。反之亦是如此。

3. 生产者消费者模型的优点之削峰填谷

假设有一个这样的场景:

9月1号开学了。全国各地的大学生需要在网上购买火车票。

这时有两个服务器 A 和 B,A 负责将请求传到阻塞队列,然后 B 服务器从阻塞队列拿到请求并进行处理。然后通过阻塞队列将响应返回给 A 服务器。

在这里插入图片描述

这时我们看到在9月1号这天,A服务器的请求量突然大增,A服务器的压力大增。从而导致阻塞队列的压力大增,但是由于阻塞队列也只是用来存放请求的。所以它完全可以承担下来。但是对于B服务器来说它并没有压力。B服务器还是继续以自己原有的速度 “消费请求”。假如阻塞队列里面 “满了”,阻塞队列就会进入阻塞状态。

当来到了9月2号,请求量一下子便下降了很多。更可能比平常的还要少。但是这对于B服务器来说还是没有影响,它还是以原来的速度在 “消费请求”。当阻塞队列为空时,阻塞队列进入阻塞。

所谓的削峰填谷就是让某一时间段里因为特殊情况而暴增所积攒的请求,以平常的速度进行处理。将其填充到平时请求量不多的后序时间段。从而起到保护B服务器的效果。

具体例子可以参考 三峡大坝!!!

雨季蓄水,旱季放闸。保证了下游水资源的充沛。(不至于闹洪灾、闹旱灾!)

4. 消息队列

上面所介绍的阻塞队列在实际开发中并不只是一个简单的数据结构,它是一个或一组服务器程序。当然它除了有阻塞队列的功能外,还提供了一些其他的功能,比如支持数据持久化存储、支持多个数据通道等。

所以就给它起了一个新名字 “消息队列”!!!

5. Java标准库中的阻塞队列

java给我们提供的阻塞队列的用法其实和普通的队列也并没有什么不同的。就是额外的提供了带阻塞的出队和入队的方法。

代码:

public class Demo {
    public static void main(String[] args) throws InterruptedException {
        //java中的阻塞队列(可以通过泛型来指定所要入队的元素的类型)
        BlockingQueue<String> queue = new LinkedBlockingQueue<>();
        //如果使用数组的形式来实现阻塞队列,那么在初始化的时候就需要指定大小
        BlockingQueue<String> queue2 = new ArrayBlockingQueue<>(10);

        //带阻塞的入队方法(需要声明异常)
        queue.put("hello");
        //出队方法
        String take = queue.take();
        System.out.println(take);
    }
}

6. 自定义实现一个阻塞队列

这里就使用数组的方式来实现一个阻塞队列。

步骤:

  • 实现一个普通的队列。
  • 实现线程安全。
  • 实现阻塞。

1. 实现普通的队列

在我们学习过的数据结构中,我们就已经自己实现过普通队列了。

总结一下我们实现队列时需要考虑的问题:

  1. 我们需要实现一个循环队列,当队头 head 不在是第一块空间时,对头前面的空间要能进行使用。

解决办法:

  1. 使用if判断,队尾指针是否到达了数组末尾。
if(tail == array.length-1){
	tail = 0;
}

  1. 使用求余运算符。
tail = (tail+1)%array.length;

  1. 我们在进行入队操作时需要判断当前队列是否为满。

解决办法:

  1. 浪费一块空间,当 head == tail 时为空, tail + 1 == head 时为满。
  2. 使用额外的一个变量 size 来记录当前队列中的元素的个数。当 size == array.length 时为满,size == 0 时为空。

当了解了上面的问题后,我们就可以进行具体代码的编写了。

//这里为了简洁就不使用泛型,默认存放的元素为 int
class MyLockingQueue{
    //使用数组来存放元素,固定大小 1000
    private int[] array = new int[1000];
    //统计当前队列中元素的多少
    private int size = 0;
    //队头和队尾
    private int head = 0;
    private int tail = 0;

    //入队方法
    public void put(int value){
        //先判断是否满了
        if(size == array.length){
            //暂时先直接返回
            return;
        }
        //将元素放到队列中
        array[tail] = value;
        tail++;    //队尾往后移
        //循环处理队尾
        if(tail >= array.length){
            tail = 0;
        }
        //统计元素的值加1;
        size++;
    }

    //出队方法
    public Integer take(){
        //先判断队列是否为空
        if(size == 0){
            //暂时也先返回
            return null;
        }
        //拿到队首元素
        int result = array[head];
        //队首往后移
        head++;
        //循环队首的处理
        if(head >= array.length){
            head = 0;
        }
        //记录减一,并返回拿到的元素
        size++;
        return result;
    }
}

public class Test {
    public static void main(String[] args) {
        MyLockingQueue queue = new MyLockingQueue();
        //入队列
        queue.put(1);
        queue.put(2);
        queue.put(3);
		//出队列
        Integer take = 0;
        take = queue.take();
        System.out.println(take);
        take = queue.take();
        System.out.println(take);
        take = queue.take();
        System.out.println(take);
    }
}

2. 实现线程安全的队列

在上面我们已经实现了一个普通的队列,并进行了测试。接下来我们需要通过分析将上述的普通队列改成一个线程安全的。

分析:我们通过观察发现在上面的代码中,put() 和 take() 方法里面有许多的读取判定修改操作,所以我们可以直接使用 synchronized 关键字来上锁。

代码如下:

//这里为了简洁就不使用泛型,默认存放的元素为 int
class MyLockingQueue{
    //使用数组来存放元素,固定大小 1000
    private int[] array = new int[1000];
    //统计当前队列中元素的多少
    private int size = 0;
    //队头和队尾
    private int head = 0;
    private int tail = 0;

    //入队方法(使用 synchronized 关键字上锁)
    synchronized public void put(int value){
        //先判断是否满了
        if(size == array.length){
            //暂时先直接返回
            return;
        }
        //将元素放到队列中
        array[tail] = value;
        tail++;    //队尾往后移
        //循环处理队尾
        if(tail >= array.length){
            tail = 0;
        }
        //统计元素的值加1;
        size++;
    }

    //出队方法(使用 synchronized 关键字上锁)
    synchronized public Integer take(){
        //先判断队列是否为空
        if(size == 0){
            //暂时也先返回
            return null;
        }
        //拿到队首元素
        int result = array[head];
        //队首往后移
        head++;
        //循环队首的处理
        if(head >= array.length){
            head = 0;
        }
        //记录减一,并返回拿到的元素
        size++;
        return result;
    }
}

3. 实现队列阻塞

在这里我们为了实现阻塞效果,可以使用 wait() / notify() 来实现。

原理:

  • 队列为满时,入队列操作进行阻塞,这时出队列必然不会阻塞,所以当出了一个元素时,在出队列操作里就可以使用 notify() 来通知入队列操作。
  • 队列为空时,出队列操作进行阻塞,这时入队列必然不会阻塞,所以当入了一个元素时,在入队列操作里就可以使用 notify() 来通知出队列操作。

具体代码:

//这里为了简洁就不使用泛型,默认存放的元素为 int
class MyLockingQueue{
    //使用数组来存放元素,固定大小 1000
    private int[] array = new int[1000];
    //统计当前队列中元素的多少
    private int size = 0;
    //队头和队尾
    private int head = 0;
    private int tail = 0;

    //入队方法(使用 synchronized 关键字上锁)
    synchronized public void put(int value) throws InterruptedException {
        //先判断是否满了
        if(size == array.length){
            //如果队列满了,还要入队列就进行阻塞等待。
            this.wait();
        }
        //将元素放到队列中
        array[tail] = value;
        tail++;    //队尾往后移
        //循环处理队尾
        if(tail >= array.length){
            tail = 0;
        }
        //统计元素的值加1;
        size++;
        //入队列成功,说明里面有元素了,进行通知
        this.notify();
    }

    //出队方法(使用 synchronized 关键字上锁)
    synchronized public Integer take() throws InterruptedException {
        //先判断队列是否为空
        if(size == 0){
            //如果队列为空了,还要出队列,也进行阻塞等待
            this.wait();
        }
        //拿到队首元素
        int result = array[head];
        //队首往后移
        head++;
        //循环队首的处理
        if(head >= array.length){
            head = 0;
        }
        //记录减一,并返回拿到的元素
        size--;
        //出队列成功,说明当前队列不为满了,进行唤醒
        this.notify();
        return result;
    }
}

在上面的代码中我们的锁对象是 this。如果不是很好理解,那么我们可以专门创建一个锁对象来操作,相比于this更好理解。

具体代码:

//这里为了简洁就不使用泛型,默认存放的元素为 int
class MyLockingQueue{
    //使用数组来存放元素,固定大小 1000
    private int[] array = new int[1000];
    //统计当前队列中元素的多少
    private int size = 0;
    //队头和队尾
    private int head = 0;
    private int tail = 0;

    //创建一个锁对象
    private Object locker = new Object();

    //入队方法(使用 synchronized 关键字上锁)
    public void put(int value) throws InterruptedException {
        //使用同步锁,手动指定锁对象为 locker
        synchronized(locker){
            //先判断是否满了
            if(size == array.length){
                //如果队列满了,还要入队列就进行阻塞等待。
                locker.wait();
            }
            //将元素放到队列中
            array[tail] = value;
            tail++;    //队尾往后移
            //循环处理队尾
            if(tail >= array.length){
                tail = 0;
            }
            //统计元素的值加1;
            size++;
            //入队列成功,说明里面有元素了,进行通知
            locker.notify();
        }
    }

    //出队方法(使用 synchronized 关键字上锁)
    public Integer take() throws InterruptedException {
        synchronized(locker){
            //先判断队列是否为空
            if(size == 0){
                //如果队列为空了,还要出队列,也进行阻塞等待
                locker.wait();
            }
            //拿到队首元素
            int result = array[head];
            //队首往后移
            head++;
            //循环队首的处理
            if(head >= array.length){
                head = 0;
            }
            //记录减一,并返回拿到的元素
            size--;
            //出队列成功,说明当前队列不为满了,进行唤醒
            locker.notify();
            return result;
        }
    }
}

4. 使用自定义阻塞队列实现一个简单的生产者消费者模型

自定义阻塞队列我们在上面已经实现了,在这里只需要创建一个生产者和一个消费者线程,然后分别进行入队和出队操作。最后观察结果看看是否会进行阻塞。

具体代码:

    //实现一个简单的生产者消费者模型
    public static void main(String[] args) {
        //创建一个自定义阻塞队列的实例。(阻塞队列实现的代码在上面)
        MyLockingQueue queue = new MyLockingQueue();

        //一个生产者线程
        Thread producer = new Thread(()->{
            int number = 1;
            while(true){
                //入队
                try {
                    //生产
                    queue.put(number);
                    System.out.println("生产了第 "+number+" 个资源");
                    number++;
                    //加上sleep能更好的观察结果(生产一个消费一个)
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        //启动线程
        producer.start();

        //一个消费者线程
        Thread consumer = new Thread(()->{
            while(true){
                //入队
                try {
                    Integer take = queue.take();
                    System.out.println("消费了第 "+take+" 个资源");
                    //(先一下子生产1000个,然后消费一个生产一个)
                    //Thread.sleep(2000);  
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        //启动线程
        consumer.start();
    }

16. 定时器

定时器是干啥的?

顾名思义,定时器就是规定一个时间之后,执行指定的任务。

1. Java标准库中的定时器

在 Java 中已经给我们提供了一个定时器,它是 java.util 包下的 Timer 类。

使用方法:

public class Test {
    public static void main(String[] args) {
        //创建一个Timer类型的实例
        Timer timer = new Timer();
        //调用它的schedule方法(注册任务)
        // 该方法有两个参数:
        // 1. 需要完成的任务是什么,这个参数的类型为TimerTask类型,它是一个抽象类,实现了Runnable接口。
        // 2. 多少时间间隔后开始执行任务。
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("我是一个TimerTask任务!!!");
            }
        },3000);
        System.out.println("main...");
    }
}

2. 自定义实现一个定时器

当我们了解了 java 标准库给我们提供的方法后,我们就可以仿照它实现一个自己的定时器。

步骤:

  1. 描述一个任务。
  2. 使用数据结构来组织一些任务。
  3. 执行时间到了的任务。

1. 描述任务

我们可以使用一个 MyTimerTask 类来进行描述:

  • 有一个 Runnable 类型的字段用来存储任务的内容。
  • 还有一个 long 类型的变量 time 用来记录任务开始的时间。(毫秒级)
  • 一个有参构造方法,用来创建任务对象实例。(参数一:Runnable类型的任务内容;参数二:当前到任务开始的间隔时间)
  • 一个任务执行的方法,run() 方法。
  • 一个 time 字段的 get 方法。
  • 让 MyTimerTask 类实现 Comparable 接口,并重写 compareTo() 方法。(重要,在组织任务时需要制定比较规则)
//描述一个任务
class MyTimerTask implements Comparable<MyTimerTask>{
    //任务的内容
    private Runnable runnable;
    //任务开始的时间
    private long time;

    //有参构造方法,来创建任务
    public MyTimerTask(Runnable runnable,long delay){
        this.runnable = runnable;
        this.time = System.currentTimeMillis() + delay;
    }

    //获取毫秒级时间
    public long getTime(){
        return time;
    }

    //执行任务的方法
    public void run(){
        runnable.run();
    }

    @Override
    public int compareTo(MyTimerTask o) {
        //按执行时间的从小到大顺序比较
        return (int) (this.time - o.time);
    }
}

2. 组织任务

在上面我们已经描述了一个具体的任务,接下来就需要编写自定义的定时器了。

  • 使用一个 优先级阻塞队列(PriorityBlockingQueue) 来存放多个任务。
  • 使用一个 schedule()方法,将任务添加到队列中,也就是进行任务的注册。
//自定义定时器
class MyTimer{
    //存放多个任务
    private PriorityBlockingQueue<MyTimerTask> queue = new PriorityBlockingQueue<>();
    
    //注册任务
    public void schedule(Runnable runnable,long delay){
        //创建一个任务
        MyTimerTask myTimerTask = new MyTimerTask(runnable, delay);
        //将任务放到队列中
        queue.put(myTimerTask);
    }
}

3. 执行时间到了的任务

这里的逻辑还是要写在 MyTimer 类中。

  • 使用构造方法,创建一个线程,让它无时无刻都在检测队首任务的执行时间是否到了。
//自定义定时器
class MyTimer{
    //存放多个任务
    private PriorityBlockingQueue<MyTimerTask> queue = new PriorityBlockingQueue<>();

    //使用构造方法来检测当前任务是否到达了需要执行的时间
    public MyTimer(){
        //创建一个线程
        Thread thread = new Thread(()->{
            //循环进行检测
            while(true){
                try {
                    //拿到了队首任务
                    MyTimerTask take = queue.take();
                    //获取当前时间,并进行判断
                    long curTime = System.currentTimeMillis();
                    if(curTime < take.getTime()){
                        //还没有到时间,重新放回去
                        queue.put(take);
                    }else{
                        //时间到了,开始执行任务。
                        take.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        //启动线程
        thread.start();
    }

    //注册任务
    public void schedule(Runnable runnable,long delay){
        //创建一个任务
        MyTimerTask myTimerTask = new MyTimerTask(runnable, delay);
        //将任务放到队列中
        queue.put(myTimerTask);
    }
}

4. 忙等问题

上面的代码基本上就实现了一个定时器,但是我们通过观察便会发现,MyTimer类 中的循环检测队首任务的执行时间的操作很不合理。由于我们没有进行限制,所以 while 循环会转的非常快。所以就出现了忙等,意思就是thread 线程确实在等,但是它又没有闲着。

解决办法?

  • 给它一个指定的等待时间:当前时间 与 队首任务执行时间 的差值。
  • 每次添加任务成功后,都要进行唤醒,避免 thread 线程在等待的任务比最新注册的任务的执行时间晚。
//自定义定时器
class MyTimer{
    //存放多个任务
    private PriorityBlockingQueue<MyTimerTask> queue = new PriorityBlockingQueue<>();

    //创建一个锁对象
    private Object locker = new Object();

    //使用构造方法来检测当前任务是否到达了需要执行的时间
    public MyTimer(){
        //创建一个线程
        Thread thread = new Thread(()->{
            //循环进行检测
            while(true){
                try {
                    //拿到了队首任务
                    MyTimerTask take = queue.take();
                    //获取当前时间,并进行判断
                    long curTime = System.currentTimeMillis();
                    if(curTime < take.getTime()){
                        //还没有到时间,重新放回去
                        queue.put(take);
                        //如果时间还没到,就等待当前时间和任务开始的时间的差值
                        synchronized(locker){
                            //wait 可以中途被唤醒,也可以指定等待时间
                            locker.wait(take.getTime()-curTime);
                        }
                    }else{
                        //时间到了,开始执行任务。
                        take.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        //启动线程
        thread.start();
    }

    //注册任务
    public void schedule(Runnable runnable,long delay){
        //创建一个任务
        MyTimerTask myTimerTask = new MyTimerTask(runnable, delay);
        //将任务放到队列中
        queue.put(myTimerTask);
        //每次插入一个新任务,就需要重新检测一遍
        synchronized (locker){
            //将 thread 线程唤醒
            locker.notify();
        }
    }
}

最后可以通过以下代码进行测试:

//对定时器进行测试
public class Test {
    public static void main(String[] args) {
        //创建一个定时器
        MyTimer myTimer = new MyTimer();
        //注册任务
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                //任务内容
                System.out.println("hello MyTimer...");
            }
            //当前时间的3秒后开始执行
        },3000);
        //main线程的任务内容
        System.out.println("main...");
    }
}

17. 线程池

1. 为什么要有线程池

  • 我们知道在计算机中有进程,但是进程的创建和销毁开销很大。所以我们为了解决这个问题,就有了进程池和线程。而进程池的本质还是要创建销毁进程,线程相比较而言更加轻量,所以我们更偏向使用线程。后来,由于我们的要求变高,线程频繁的创建和销毁我们也觉得开销大。所以为了解决这个问题,就又有了线程池和协程。

2. 线程池是啥

  • 线程池就是一块空间,在我们还没有使用线程的时候,就已经提前创建好了线程并放到了这块空间,也就是池子里。当我们需要使用线程时,我们就可以直接从池子里拿出线程进行使用,不用了就放回池子里。这样在创建或销毁线程时就不需要从操作系统那儿申请了。速度就会更快!!!

3. 为什么直接从线程池中拿就比从操作系统那儿申请快

要知道这个问题的答案,首先我们需要了解什么是 内核态 ? 什么又是 用户态?

1. 用户态 和 内核态

我们知道计算机软硬件结构大致为:

用户态 : 就是在应用程序层中运行的我们自己所写的代码。即用户态运行代码。

内核态 : 我们写的代码有时候会调用 API ,这时就有一部分代码需要通过系统调用在内核中执行。即内核态运行代码。比如 System.out.println 这个代码就需要在内核中执行一堆逻辑,控制显示器打印字符串。


2. 原因

在了解了用户态和内核态后,我们还需要知道一个线程的创建是需要通过系统调用到内核中执行一些逻辑的。

(创建线程的本质:就是在内核中创建一个 PCB ,然后将 PCB 加入链表中进行组织)

为什么快?

举个例子:

有一天,张三来到银行的柜台前,跟工作人员说,要办一张银行卡。

工作人员就问他: “你带了身份证复印件没?”

张三回答: “没带复印件,但我带了原件”

工作人员就接着说: “你是自己去左边的复印机那里复印,还是把原件给我,我去里面的复印机复印?”

这时如果张三将身份证原件给工作人员去复印,就相当于是经过内核态的操作。这种情况下一旦工作人员离开柜台,那么我们就不知道他是否在复印身份证的过程中干了一些其他的事,比如:聊了会儿天,查看了一新闻等。这种情况是我们不可控的,所以我们就说需要内核态运行代码的操作是比较"慢"的。

如果是张三自己去复印,就相当于是纯用户态操作(只需要在应用程序层执行)。这种情况下张三就是直接去复印,然后回来。整个过程是可控的,所以我们就说用户态运行代码的操作是"快"的。

总结:

其实就是可不可控的问题。

往线程池里放线程和拿线程是纯用户态操作,代码只在应用程序层完成,整个过程完全可控。

通过系统申请创建线程,就需要有内核态的操作,这时在内核中执行的代码是不可控的。(虽然它最终能达到预期的效果,但是它在过程中可能会干一些其它的事!!!)

所以我们就说直接从线程池中拿比向操作系统申请更快!!!


4. 标准库中的线程池

java标准库中也是给我们提供了线程池 — ThreadPoolExecutor ,它是在 java.util.concurrent 包下。

它有许多的构造方法,我们直接分析一下参数最多的那一个:

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) 

corePoolSize: 核心线程的个数 (相当于一个公司的正式员工) (重要)

maximumPoolSize :最大线程的个数 (公司里的正式员工和非正式员工(临时工)的总和) (重要)

keepAliveTime : 非核心线程的最大空闲时间 (临时工的摸鱼时间,超过就辞退)

unit :时间单位

workQueue :任务队列(通过 submit 方法将任务注册到线程池中,并加到任务队列里)

threadFactory :线程工厂(线程是怎么创建出来的)

handler :拒绝策略(处理如果任务队列满了,还有任务来的情况。比如:直接忽略、阻塞等待、丢弃最老的任务并将新任务进行添加。)

5. 一个线程池中,初始创建多少线程合适?

首先我们要明确,给出任何一个确定的值的回答都是错的!!!

需要多少合适,这是需要我们进行 性能测试 来找到一个较为合适的值。


举个例子:

比如我要写一个服务器程序,在这个程序中使用线程池来处理请求。那么我需要自己构造一些请求,发送给服务器,然后观察服务器的 执行速度 和 CPU 占用率。找到一个速度还可以,CPU占用率也是合理的平衡点。


为什么要 CPU 占用率要合理?

首先我们要知道 执行速度 和 CPU 占用率是两者不可兼得的。

线程多了,执行速度就快,但CPU占用率就很高。

线程少了,CPU占用率就低,但执行速度就低了。

同时任何一个线上服务器都要留有一定的空间来应对所出现的突发情况,比如春运这种突然请求暴涨的情况。

所以才需要找到一个平衡点。

6. 标准库中线程池的使用

从上面的第四点中我们了解了java提供的线程池(ThreadPoolExecutor )的构造方法,但我们发现这个类的构造方法的参数过多,导致使用起来并不是很方便。

所以在标准库中还给我们提供了一个简单版本的线程池(Executors),它本质上就是对 ThreadPoolExecutor 进行了封装,提供了一些默认的参数,从而让我们使用起来更加的简单。

使用代码:

public class Test {
    public static void main(String[] args) {
        //创建一个固定线程数目的线程池,参数指定线程个数         (最常用)
        ExecutorService pool = Executors.newFixedThreadPool(10);
//        //创建一个可以自动扩容的线程池,根据任务量自动扩容
//        Executors.newCachedThreadPool();
//        //创建一个只有一个线程的线程池
//        Executors.newSingleThreadExecutor();
//        //创建一个带有定时器功能的线程池
//        Executors.newScheduledThreadPool();

        //循环注册50个任务
        for(int i = 0; i < 50; i++){
            // submit方法为注册任务的方法
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("正在工作中...");
                }
            });
        }
    }
}

7. 自定义实现一个线程池

想要实现一个最简单的线程池,那么我们需要完成以下的步骤:

    1. 描述一个任务(这里与定时器不同,可以直接使用 Runnable 来进行描述)
    2. 组织多个任务(这里需要使用数据结构 :阻塞队列(BlockingQueue)完成)
    3. 描述一个具体的线程(可以使用静态内部类的方式来完成)
    4. 组织多个线程(也是需要使用数据结构来完成:ArrayList 就可以)
    5. 和标准库中一样,提供一个 submit 方法来让用户注册任务

具体代码

//自定义创建一个线程池
class MyThreadPool{
    //1.描述一个任务,直接使用 Runnable
    //2.组织一些任务,使用阻塞队列
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();

    //3.具体的描述一个线程
    static class MyThread extends Thread{
        private BlockingQueue<Runnable> queue = null;

        public MyThread(BlockingQueue<Runnable> queue){
            this.queue = queue;
        }
        @Override
        public void run() {
            //每个线程的工作内容:从任务队列中循环拿任务,然后进行运行
            while(true){
                //由于这里我们需要使用 MyThreadPool 类中的 queue 任务队列
                //所以使用构造方法传参的方式来解决
                try {
                    //队列为空,阻塞
                    Runnable take = queue.take();
                    take.run();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    //4.组织多个线程
    private List<Thread> list = new ArrayList<>();
    //提供一个构造方法来指定初始化线程的个数
    public MyThreadPool(int number){
        //创建线程 -> 启动线程 -> 添加到线程数组中
        MyThread myThread = new MyThread(queue);
        myThread.start();
        list.add(myThread);
    }

    //5.提供一个submit方法来让程序员可以注测任务
    public void submit(Runnable runnable){
        //将任务添加到任务队列中
        try {
            queue.put(runnable);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

测试代码:

//进行自定义线程池的测试
public class Test {
    public static void main(String[] args) {
        //创建线程池,指定10个线程
        MyThreadPool myThreadPool = new MyThreadPool(10);
        //循环注册50个任务,每个任务都是打印一句 : hello word...
        for (int i = 0; i < 50; i++) {
            myThreadPool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello word...");
                }
            });
        }
    }
}

18. 常见的锁策略

锁策略和普通程序员没有关系,但和 实现锁 的人有关系。

锁策略和java本身没有关系,它只和需要用到锁的情况有关。

1. 乐观锁 vs 悲观锁

乐观锁:预期锁冲突的概率很低。(所做的事较少,开销更小,效率较高)

悲观锁:预期锁冲突的概率很高。(所做的事较多,会付出额外的成本,开销更大,效率更低)

2. 读写锁 vs 普通互斥锁

普通的互斥锁只有两种情况:加锁 / 解锁

读写锁有三种情况:读加锁 / 写加锁 / 解锁

  • 当程序中只有读操作时,加读锁。
  • 当程序中有写操作时,加写锁。

读写锁为什么要将加锁分成两种?

  1. 这样就可以把 读锁 和 读锁 这种情况给分出来,这种情况下是不会出现互斥的。
  2. 只有在 读锁 和 写锁 、写锁 和 写锁 这两种情况下才会出现互斥。
  3. 并且在大多数的场景下,读操作的频率要比写操作的高很多。

总结:通过以上的三点,我们就能发现读写锁比普通的互斥锁的效率高很多。

3. 重量级锁 vs 轻量级锁

这一组锁和乐观锁/悲观锁很相似。

重量级锁:工作内容更多,开销更大,效率很低。

轻量级锁:工作内容较低,开销较小,效率较高。

在一般情况下:

  • 悲观锁都是重量级锁。
  • 乐观锁都是轻量级锁。

但世事无绝对,我们可以这样理解:

乐观锁/悲观锁相当于做一件事的态度:某件事如果做起来,可能会很困难,就需要更多的工作内容(悲观锁)。

而重量级锁/轻量级锁是事情做完后的结果:某件事已经做完了,我们发现在完成事件的过程中做了很多的工作(重量级锁)。

我们一般认为:

在使用锁的过程中,如果锁是基于内核中的一些功能来实现的(比如调用了操作系统的 mutex 接口),那么此时我们就认为这是重量级锁。

(因为操作系统的锁会在内核中做很多的事情,比如阻塞等待)

如果是通过纯用户态实现的锁,我们就认为它是轻量级锁。

(因为它更加可控,更加高效)

4. 挂起等待锁 vs 自旋锁

挂起等待锁 往往是通过内核来实现的,所以我们就认为它是重量级锁的一种典型实现

自旋锁 往往就是通过纯用户态实现的,所以我们也就认为它是轻量级锁的一种典型实现

5. 公平锁 vs 非公平锁

公平锁:多个线程等待同一把锁的时候,谁先来等待的,谁就先获取这把锁。

(遵循先来后到原则)

非公平锁:多个线程等待同一把锁的使用,不遵循先来后到原则。

(也就是说每个线程获取这把锁的概率是均等的)

在操作系统中,本身线程的调度就是随机的(机会均等的)。操作系统提供的 mutex 这个互斥锁就属于 非公平锁。

想要实现公平锁,就需要付出更多的代价。使用一个队列,给线程排一排先来后到。

6. 可重入锁 vs 不可重入锁

这个在上面第11点讲过,比如针对一个线程一把锁的情况:

一个线程对同一把锁连续加锁多次,如果出现 死锁 情况,那么就是不可重入锁;没出现 死锁,就是可重入锁。

7. synchronized 所对应的锁策略

  • synchronized 既是一把 乐观锁,也是一把 悲观锁。(它会根据锁竞争的激烈程度,自适应)
  • synchronized 不是读写锁,它只是一把普通互斥锁。
  • synchronized 既是轻量级锁,又是一把重量级锁。(也是根据锁竞争的激烈程度,自适应)
  • synchronized 轻量级锁的部分由自旋锁实现,重量级部分由挂起等待锁实现。
  • synchronized 是一把非公平锁。
  • synchronized 是一把可重入锁。

19. CAS

CAS 全称:compare and swap。

CAS 工作流程:拿着 寄存器/某个内存中的值,与另一个内存中的值进行比较,如果相同了。就将 寄存器/内存中的值与还有一个内存中的值进行交换。

上面的解释并不是很好理解,使用下面的伪代码进行理解将会更好一些

//address:被比较的值的内存空间     expectValue:预期内存中旧的值      swapValue:需要交换的值
boolean CAS(address, expectValue, swapValue) {
 //先判断这块内存中是否是预期的值
 if (&address == expectedValue) {
   //是的话,就进行交换
   &address = swapValue;
     	//表示操作成功
        return true;
   }
    //表示操作失败
    return false;
}

此处的 CAS 指的是 CPU 提供的一条指令,而这条指令就完成了上述伪代码的全部操作。

通过一条指令完成上述操作 与 使用伪代码 完成有什么不同?

通过一条指令完成,它是线程安全的。因为一条指令是 CPU 执行时不可分割的基本单位。它天然的就不会出现线程安全问题。所以它的执行效率就会很高。

如果使用伪代码这种方式,就会出现线程安全问题。因为在这段代码中的读取判断修改操作不是原子的。

**总结:CAS为我们编写线程安全的代码提供了另一种方式,就不需要通过加锁的方式来保证线程安全。(代码本身就是线程安全的了)**由此可以联想到很多的功能有两种实现方式,硬件和软件。比如上面的比较交换,这是硬件直接给我们实现了,通过这一条指令,封装好后提供给我们程序员使用。

1. CAS 能帮我们干啥?

1. 基于 CAS 能够实现 “原子类”

在 java 标准库中提供了一组 “原子类”,然后针对一些常用的类型 int、long等进行了封装。可以基于 CAS 的方式进行修改,同时保障了线程安全。

具体代码:

public class Test {
    public static void main(String[] args) throws InterruptedException {
        //初始化值为0
        AtomicInteger integer = new AtomicInteger(0);

        //创建线程 t1 将 integer 自增 50000
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //相当于 integer++ 操作
                integer.getAndIncrement();
            }
        });

        //创建线程 t2 将 integer 再自增 50000
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //相当于 integer++ 操作
                integer.getAndIncrement();
            }
        });
		//启动线程
        t1.start();
        t2.start();
		//等待 t1 和 t2 线程执行完毕
        t1.join();
        t2.join();
		//查看 integer 的值
        System.out.println(integer.get());
    }
}

运行代码后,我们就会发现整个程序的结果就是 100000。并没有出现线程安全问题,并且在上述代码中我们也没有加锁。所以我们通过这个例子,便证明了这个类是基于 CAS 的方式进行修改的。


解读 integer.getAndIncrement()

为了更好理解,我们使用下面的一段伪代码来进行分析:

class AtomicInteger {
    private int value;
    //原子类中相当于 num++ 的方法
    public int getAndIncrement() {
        //由于我们不好使用伪代码表示寄存器读数据,所以这里使用赋值操作来表示
        int oldValue = value;  //将其理解为寄存器从内存中读取数据
        //然后进行循环 CAS 操作,判断读取的数据和原先的数据是否相等,相等就+1然后进行交换。
        while ( CAS(value, oldValue, oldValue+1) != true) {
            //如果 CAS 返回 false,说明不相等,就循环进行下一次读取
            oldValue = value;
       }
       return oldValue;
   }
}

图解分析:

在这里插入图片描述

通过上面的画图分析,我们可以很清晰的看到,getAndIncrement() 方法基于 CAS 方式进行修改的具体流程。通过 CAS 将 判断 ,+1 ,交换 封装成一条指令后,天然的解决了线程安全问题。并且由于不会阻塞,所以比之加锁(synchronized)效率更高。

2. 基于 CAS 实现 自旋锁

这里还是通过一段伪代码进行理解:

public class SpinLock {
    
    //表示持有锁的线程对象
    private Thread owner = null;
    
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有.(owner 是否为 null) 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. (进行循环,直到 owner 等于 null)
        // 如果这个锁没有被别的线程持有(owner == null), 那么就把 owner 设为当前尝试加锁的线程. (进行交换)
        while(!CAS(this.owner, null, Thread.currentThread())){
            //自旋等待 其实就是前面说的 忙等,这会浪费一定的CPU资源。
            //但是 自旋锁 是一把轻量级锁,同时也是一把 乐观锁。
            //而是 乐观锁,就表明锁竞争并不激烈,换句话说就是等不了多久就能拿到锁。
       }
   }
    public void unlock (){
        this.owner = null;
   }
}

总结:

在自旋锁中的 CAS 操作会有 “忙等” 问题。所以会浪费一些 CPU 资源。

但是如果锁被释放了,它便能第一时间拿到锁。这样效率更高。

并且 自旋锁 是 乐观锁。它的 “忙等” 的时间不会很长,浪费一点一点CPU也就无所谓了。


2. 理解 CAS 中的 ABA 问题?(重点)

什么叫 CAS 中的 ABA 问题?

  • 我们知道 CAS 指的就是 CPU 硬件给我们提供了一条指令(比较、交换)。在比较操作时,会将读到寄存器中的值与原先内存中的值进行比较,如果两个值相同,那么我们就认为线程从内存中读取了值后,到进行比较这个中间没有其他的操作。所以就会进行下一步的交换操作。
  • 然而在大多数情况下,的确是这样的。但是在某些极端情况下就会出现BUG。这就是 ABA 问题。

举个例子

有一天张三拿着自己的银行卡在ATM机上取钱。当他输入 50 点击取款时 ATM 机卡了一下,而他就又下意识的点击了一下取款

假设他的账户上面本来有 100 元。这时他连续点击了两次取款,就相当于有两个线程 t1 和 t2 在完成一次取款操作。突然在 t2 线程准备 cas 时,又来了个线程 t3 向张三转了 50 元过来

如图:

在这里插入图片描述

从上面的一系列画图分析,我们最终看到账户余额为:50,这明显出现了BUG。张三本来有100,他取了50出来。同时又有一个人向张三转账 50。最终账户余额应该为 100 元。

这就是 ABA 问题,我们可以看到造成这个问题的原因是出现了两个巧合:

  • 张三取钱时 ATM机卡了一下,导致张三下意识又点击了一次取钱操作。
  • 在 t2 线程 cas 操作之前(也就是张三在取款的瞬间),突然有一个人向张三转了50,使张三的账户余额和之前完全相同。

所以 CAS 的 ABA 问题一般是在极端情况下才会出现。


3. 解决 ABA 问题

在上面我们了解了 ABA 问题后,能否使用一种方式将这个问题解决掉?

答案是肯定的。我们可以通过添加一个版本号来解决这个问题!!!

还是张三取钱的问题,只不过我们在账户中给它添加一个版本号,每次针对余额的修改版本号都要加1,并且这个版本号只能增加,不能减小。这样每次寄存器读取值的时候,也需要将版本号一同读到。然后在 CAS 操作时,就不使用值来比较,而是比较版本号来判断是否有其他线程介入。版本号相同则进行扣款交换。

  • 使用版本号在张三取钱这个例子中,不同之处在于:假设账户初始的版本号为 1 ,则 t1 线程进行 load、CAS操作后,内存中的版本号就为 2,并且这时 t2 读取的版本号还是 1,就算这时 t3 线程给张三转了50元,那么内存中的版本号就又变成了 3,接着 t2 线程在执行 CAS 操作时,便会发现版本号不同,从而知道了有其他的线程在 t2 线程的 load 和 CAS 之间进行了修改。
  • 接着 t2 线程的 CAS 操作便会返回 false。(表示没有执行 t2 的交换操作)

图解:

在这里插入图片描述

20. synchronized中的锁优化机制(重点)

在java编译器中多于 synchronized 会进行各种的优化,在这里介绍一些典型的优化机制

只考虑JDK1.8版本下的情况

1. 锁膨胀/锁升级

这个优化机制主要体现了 synchronized 的 “自适应” 能力。

在这里插入图片描述

2. 锁粗化

什么是锁粗化?锁细化?

这里的粗细说的是锁的 “粒度”。

通俗的讲,就是加锁代码范围大,代表锁的 粒度 粗。 加锁代码范围小,锁的 粒度 就细。

编译器为什么要进行 粗化 优化?

当在一个代码中加锁之间的间隔太小(中间间隔的代码太少),这时就会出现频繁的加锁/解锁操作,这样就会导致效率降低。所以编译器就会自动的判断,在合适的位置进行粗化*。

举个例子:

  • 有一天张三的老板给张三安排了三个任务:甲、乙、丙任务。接着没过多久张三就把 甲 任务给完成。然后他就马上给老板打了个电话,把刚完成的甲任务给进行了汇报。后来汇报后没多久,张三就又完成了 乙 任务,这时,他又马上给老板打了一个电话汇报 乙 任务。最后 丙 任务也是如此完成了汇报工作。

就这个例子来说,虽然最后张三将这三个任务都完成了。但我们会发现有两点不好的:

  1. 张三完成所有任务再到汇报整个效率不高。(打电话的次数过多)
  2. 老板频繁的接到张三的电话,心里会很烦躁。(为什么就不能三个任务做完了,一起汇报?)

总结:

  • 如果锁的粒度很细的话,虽然程序的并发性很高,但是频繁的加锁/解锁就会使得开销很大。
  • 进行粗化后,虽然并发性降低了。但是程序的开销也大大降低了。(少了很多的加锁/解锁操作)

3. 锁消除

这个比较好理解,通俗的讲,就是有些代码本来不需要加锁的。但是由于程序员的粗心大意给加了锁。这时编译器就会帮我们进行锁消除优化。(也就是将锁去掉了),进而提高代码的效率。


21. JAVA中的 JUC

JUC 全称 : java.util.concurrent,在这个包底下有许多关于 多线程的操作。

1. Callable

这是一个接口,它也是用来创建线程的一种方式。

那么它与 Runnable 有什么区别?(重要)

我们在使用 Runnable 创建任务时,便会发现并不太好拿到这个线程的结果。

如果非要使用 Runnable 创建线程,并且还要拿到线程执行后的结果。那么我们只能在创建一个类,然后这个类中有两个字段:第一字段用来存放返回结果。第二个字段就是一把锁。这样在线程运行时将结果存放到这个类中。然后就可以通过这个类拿到该线程的返回结果了。

(从上面的描述中,我们可以发现这一系列的操作是比较复杂的)

所以为了解决这个问题,就有了 Callable 接口。

Callable 的使用方式:

public class Test {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //使用 callable 来描述一个任务的具体内容
        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 可以对应一个 Callable 任务。这样我们就可以通过 FutureTask 来获取我们所需要的 Callable 任务的返回结果
        FutureTask<Integer> task = new FutureTask<>(callable);

        //将 FutureTask 传给 Thread,并启动线程
        Thread thread = new Thread(task);
        thread.start();

        //通过 FutureTask 的 get 方法来获取对应的 Callable 任务的返回值。
        //注意如果 Callable 任务没有执行完,则 get 就会进行阻塞,直到任务执行完毕,才会真正执行 get 方法,拿到具体的返回值。
        System.out.println(task.get());
    }
}

为了更好的理解,我们来举个例子:

假如我们去面馆吃面,我点了一碗肉丝面 (这相当于给厨师安排了任务,也就是 new Callable)

然后服务员听到了后就给了我一张小票,小票上记录了我点的肉丝面 (相当于使用 FutureTask 将 Callable 对应起来)

接着厨师就开始给我做起了面 (就相当于创建了线程并使用 start 启动了线程)

我等了一会儿,就拿着小票去问服务员我的面做好了没 (这时就相当于 task.get() 操作,获取返回值)

如果面还没有做好,那么只能继续等 (就相当于如果 Callable 任务没有执行完,执行到 get() 方法就会进行阻塞等待)

又过了一会儿,我又来问面做好了没。服务员说做好了,便给了我一碗肉丝面 (这时就相当于再次调用 get() 方法时,发现任务执行完了。就顺利拿到了返回值)

在上面使用 get() 方法时,会有两个异常:

InterruptedException:这个在多线程中很常见,表示阻塞状态被打断了异常。

ExecutionException:这个则是表示 任务执行过程中出现了异常。

总结:

通过上面的例子具体的代码分析,我们可以看到,当一个线程有返回值时,使用 Callable 就比 Runnable 更加方便、简单。

2. ReentrantLock

ReentrantLock 就是 可重入锁

1. 基础用法

ReentrantLock 有两个方法:lock() 和 unlock() 方法,也就是 加锁/解锁 方法。

(ReentrantLock 把加锁/解锁分成了两个操作来完成!!!)

具体使用:

public class Test {
    public static void main(String[] args) {
        //创建一把 ReentrantLock 锁
        ReentrantLock locker = new ReentrantLock();

        //上锁
        locker.lock();
        try{
            //工作内容...
        }finally {
            //通过 finally,使它即使在工作时出现异常也会进行解锁操作
            //如果不使用 try finally 将它包裹,导致 unlock 执行不到,就会出现 死锁 情况
            locker.unlock();
        }
    }
}

通过上面的代码我们可以看到:

ReentrantLock 在使用的时候比较麻烦,特别是 解锁 时需要特殊处理,防止因为抛出异常而使 unlock() 执行不到,导致 死锁 情况出现。

2. ReentrantLock 与 synchronized 的区别

  1. synchronized 是一个关键字(背后的逻辑是 JVM 实现的,也就是 C++ 代码编写的),而 ReentrantLock 是标准库中的一个类(背后的逻辑是java代码编写的 )。

  2. synchronized 不需要手动的释放锁(出代码块就解锁),而 ReentrantLock 需要通过 try finally 来进行手动的释放解锁。以此来防止出现死锁情况。

  3. synchronized 如果竞争锁的时候失败了,就会进行阻塞等待。而 ReentrantLock 如果竞争锁的时候失败了,它除了阻塞等待以外,还可以使用 trylock 直接返回。

  4. synchronized 是非公平锁,而 ReentrantLock 提供了 公平锁 和 非公平锁 两个版本。

    (在创建实例的时候,添加一个 true 参数就是 公平锁版本,不填/填false 就是非公平锁版本)

  5. 基于 synchronized 衍生出来的等待机制是 wait/notify,功能比较有限。而基于 ReentrantLock 衍生出来的等待机制是 Condition类 (条件变量),功能更加丰富一些。

3. Semaphore

Semaphore 中文叫 信号量,它是一个更广义的锁。

或者说 锁 是信号量里第一种特殊情况,叫作 “二元信号量”。

1. 什么是信号量

举个例子:

一般在停车场的入口处有一块电子显示屏,在上面会显示当前停车场里有多少的空位。

当有一辆车开进去停车,电子显示屏上的数字就会 -1

当有一辆车开出来,电子显示屏上的数字就会 +1


这块电子显示屏就是 “信号量”,它表示了当前可用资源的个数。

每次 申请 一个可用资源,计数器就会 -1。这个操作就称为 P 操作,英文一般使用 acquire 表示

每次 释放 一个可用资源,计数器就会+1。这个操作就称为 V 操作,英文一般使用 release 表示

如果 计数器 显示为 0 的时候,再进行 P 操作,那么就会进行阻塞等待。


什么是 二元信号量 ?

二元信号量的可用资源只有一个,计数器的取值 非 0 ,即 1 。

(信号量就是把锁推广到了一般情况,可用资源更多的时候,去如何处理的)

public class Test {
    public static void main(String[] args) throws InterruptedException {
        //可以指定可用资源的个数
        Semaphore semaphore = new Semaphore(4);
//        //申请资源
//        semaphore.acquire();
//        //释放资源
//        semaphore.release();

        //也可以指定申请/释放的资源个数
//        semaphore.acquire(4);
//        semaphore.release(4);

        //如果可用资源为 0 时继续申请资源,就会进行阻塞
        semaphore.acquire();
        System.out.println("申请资源1");
        semaphore.acquire();
        System.out.println("申请资源2");
        semaphore.acquire();
        System.out.println("申请资源3");
        semaphore.acquire();
        System.out.println("申请资源4");
        semaphore.acquire();
        System.out.println("申请资源5");
    }
}

4. CountDownLatch

举个例子来描述:

假如我需要下载一部游戏。使用1个线程下载比较慢,所以我使用10个线程下载,每个线程下载一部分的资源。只有当10个线程将各自部分的资源给下载完成后,游戏才算下载完成!。

CountDownLatch中有两个方法:

  • countDown() :哪个线程调用了它,就表示这个线程负责的资源已经下载完成。
  • await() : 表示等待所有的线程将各自负责的资源给下载完成后,才会返回;不然就会进行阻塞。

具体代码:

public class Test {
    public static void main(String[] args) throws InterruptedException {
        //表示有 10 个线程一起完成一个游戏的下载(每个下载部分)
        CountDownLatch countDownLatch = new CountDownLatch(10);

        //循环创建10个线程,并分配任务
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(()->{
                try {
                    Thread.sleep(3000);
                    System.out.println(Thread.currentThread().getName()+"部分资源下载完成...");
                    //表示游戏的该部分资源下载完成
                    countDownLatch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            //启动线程
            t.start();
        }

        //只要有一个线程没有下载完自己负责的资源,游戏就没有下载完成。(也就是在这里进行阻塞)
        countDownLatch.await();
        System.out.println("游戏下载完成!");
    }
}


5. CopyOnWriteArrayList

这个叫做 “写时拷贝”,也叫做 “双缓冲区” 策略。

多个线程进行读操作的情况:

  • 在这种情况下,由于没有修改操作,所以不会出现线程安全问题。

多个线程进行写操作的情况:

  • 在这中情况下,当某一个线程进行修改的时候,会将原先的数据拷贝一份,然后修改拷贝的数据。当修改完毕后再替换成原先的数据。

既有线程读,又有线程写的情况:

  • 这时,进行写操作的线程还是和上面的一样,在拷贝的数据上进行修改。修改完成后再替换。而这里的读操作则会优先读取旧版本的数据,也就是说只有当修改的线程修改完毕并替换之后,读操作才能拿到新的数据

(特点:避免了读到 “修改了一半的数据”)


适用场景:

  1. 读操作多,写操作少的情况。(大量的写操作会导致副本拷贝的次数很多)
  2. 数据量小的情况。(数据大,拷贝的内容也就越多)

22. 多线程下的哈希表

在java中给我们提供了两个保证线程安全的哈希表: Hashtable / ConcurrentHashMap

接下来就介绍一下这两个的区别:

1. Hashtable

为了能更好的了解这个 Hashtable ,我们首先来观察一下在 java 中它是怎么来保证线程安全的。

//我们主要观察一下 put 和 get 方法
public synchronized V put(K key, V value)public synchronized V get(Object key)

很明显,在 Hashtable 中是通过在方法上加 synchronized 关键字来保证的。

而在方法上加 synchronized 关键字,则是表示对 this 加锁。

也就是说只要有多个线程访问 Hashtable ,不管它是什么操作(读/写),不管是操作什么数据。它都会产生竞争。

也就是说明 Hashtable 在多线程环境下产生锁竞争的概率非常大,从而使效率很低。

通俗的讲:

我们知道在java中哈希表的结构就是:一个数组,然后数组的每个元素下面都挂着一条链表,当链表长度过长时,数组就会进行扩容。

而 Hashtable 在每次有线程来调用的时候,都会将整个数组进行上锁。也就是说如果有两个线程调用这个哈希表,但是线程1操作的数据是在数组下标为0的那条链表上,而线程2则是操作数组下标为5的那条链表上的数据。本来两个线程相互没有任何的影响,但是当其中的某一条调用哈希表时,直接就将整个哈希表给上锁了。这时另一个线程就会阻塞。

产生的结果就是让本来可以并发执行的两个线程变成了串行化执行。导致运行效率大大降低。

2. ConcurrentHashMap

和 Hashtable 相比,它的锁的粒度没有那么大。Hashtable 针对整个哈希表加锁。而 ConcurrentHashMap 针对哈希表中每一条链表进行加锁。

由于在哈希表中,链表的数量是很大的,而链表的长度相对较短,所以使用 ConcurrentHashMap 产生的锁竞争就会大大降低。

3. Hashtable 和 ConCurrentHashMap 的区别总结(重点)

  1. Hashtable 针对整个哈希表加锁,ConCurrentHashMap 针对哈希表中的每条链表加锁。(粒度更小)
  2. ConCurrentHashMap 只是针对写操作加锁,而读操作只是使用 volatile 关键字来保证内存可见性问题。
  3. ConCurrentHashMap 中大量的使用 CAS 来保证线程安全问题,减少锁竞争,进一步提高了效率。(比如为维护 size 的值)
  4. Hashtable 和 ConCurrentHashMap 的扩容又不一样(ConCurrentHashMap进行了化整为零):
    • Hashtable 在put时,如果发现需要扩容,就会一口气直接将数据搬运一遍。就会导致这次 put 非常卡顿。
    • ConCurrentHashMap 则不同,由于它的锁的粒度小,所以它在扩容时,是一点点的搬运。也就是说它会维护两个哈希表,一个是旧的(需要扩容的),另一个是新的(扩容后的,但数据还没有搬运过来)。当进行插入操作时,就会在新的哈希表中插入。而查找操作则是在 旧的 和 新的 两个表中都查。最后搬运完毕后才会销毁旧的哈希表。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值