多线程——线程安全问题

一、多进程和多线程

1.操作系统的作用

操作系统针对两个群体,对上是各种程序的运行提供一个稳定的运行环境,对下则是负责管理(描述+组织)各种硬件,包括cpu、存储器(硬盘和 内存 )、输入设备、输出设备,操作系统通过PCB(进程控制块,类似于C的结构体)来描述进程。

进程控制块的关键属性:

1.PID:进程此时此刻的唯一标识;

2.内存指针(一组):分为操作码和操作数,总共八个数字,前四位为操作码,后四位为操作数(具体可以查询cpu出厂自带的指令表);

3.文件描述符表:操作系统通过文件来管理硬盘,文件打开成功就会产生一个文件描述符(整数,也类似于身份标识),由这些文件描述符组成的表就是文件描述符表;

4.状态:描述一个进程目前所处的状态(例如:就绪状态(随时可以进行调度)、阻塞状态(暂时无法调度,需要唤醒));

5.优先级:PCB中记录着哪些程序的优先级较高,哪些较低,cpu会优先运行优先级较高的程序

6.上下文:线程运行期间如果被从cpu上调度走之后会记录这时候的中间状态,下次再回来运行的时候会从这个状态中继续运行,类似于"读档"和"存档"的操作;

7.记账信息:记录了一个程序在cpu上运行的次数,如果次数过少可以分配给其一些cpu资源。

其实4、5、6、7这些都是与线程的调度强相关的。

2.进程和线程的作用

在日常编写代码时,代码的执行都是在cpu上面进行的,cpu是通过操作系统来调度线程和进程来运行电脑中的各种程序,可以说一切程序都离不开进程和线程的调度。

我们Java中不太关注进程,因为进程创建和销毁的开销相较于线程来说很大,所以我们一般只关心线程。

3.多进程和多线程的区别

假设这里有一间小屋,里面有一个人在吃鸡腿,这里就可以将小屋看成是分配的资源空间 ,吃鸡腿看作是执行任务 ,这个人就是一个线程,那么假设一个人每分钟只能吃一个鸡腿,那么这个线程要完成这些任务就需要100分钟;这就可以看作是单线程/单进程的执行任务

3.1多进程

多进程就是分配多间屋子,这些屋子里都是一个人,每间屋子分配到的鸡腿就是50根(假设有两个进程),这样就可以提高程序的执行效率。

3.2多线程

不同于多进程,多线程只需要在这间屋子里面再去添加一个火柴人就可以让这两个人去一起吃这100个鸡腿,这样这个任务也会在50分钟的时候吃完,这样也提高了效率。

线程就是更轻量的进程,它只在第一次创建的时候才消耗资源和内存空间,之后就不会再申请资源,但是线程也不是越多越好的,随着线程的增加,cpu的资源是有限的,线程多到一定程度也不会再提高效率,相反还可能降低效率。

4.创建线程

从上述情况中可以了解到线程不是越多越好的,但是线程在不多的情况下也会有线程安全问题,这里先介绍一下线程的创建方法:

4.1通过Thread类创建线程

首先可以通过实现一个自己的类来继承Thread类 并且重写**run()**方法,这里的Thread类是Java内部提供的一个类,例如:

java 复制代码
class MyThread extends Thread{
    @Override
    public void run() {
        while(true){
        System.out.println("Hello Thread!");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    }
}
public class Demo1 {
public static void main(String[] args) throws InterruptedException  {
    Thread thread = new MyThread();
    //启动线程
    thread.start();
    while(true){
        System.out.println("Hello World!");
        Thread.sleep(1000);
    }
}
}

第二种方法就是通过匿名内部类来new一个Thread类,再重写run()方法,例如:

java 复制代码
//Thread匿名内部类,重写run方法
public class Demo2 {
    public static void main(String[] args) {
        Thread thread = new Thread(){
            @Override
            public void run() {
                while(true){
                    System.out.println("Hello Thread!");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        //启动线程
        thread.start();
        while(true){
            System.out.println("Hello main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
}
}

第三种就是利用lambda表达式创建线程:

java 复制代码
public class Demo5 {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            while(true){
                System.out.println("Hello Thread!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
        });
        thread.start();
        while(true){
            System.out.println("Hello main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

4.2通过Runnable类创建线程

第一种是通过实现一个自己的类来实现Runnable接口并且重写run()方法,再将new出来的Runnable对象作为参数传递给Thread类:(Runnable接口也是Java内部提供的)

java 复制代码
//利用runnable接口实现创建线程。
//MyRunnable类实现Runnable接口,这个类要在Demo3类的外部,因为它要被Thread类使用
class MyRunnable implements Runnable {

            @Override
            public void run() {
                // TODO Auto-generated method stub
                while(true){
                    System.out.println("Hello Thread!");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
    }
public class Demo3 {
    
    public static void main(String[] args) {
     MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        //启动线程
        thread.start();
        while(true){
            System.out.println("Hello main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
}
}

第二种就是通过匿名内部类new一个Runnable对象并且重写run()方法,再将new出的对象作为参数传给Thread对象来创建线程:

java 复制代码
public class Demo4 {
    public static void main(String[] args) {
        //利用匿名内部类和Runnable接口创建接口
        Runnable runnable = new Runnable(){
            @Override
            public void run() {
                // TODO Auto-generated method stub
                while(true){
                    System.out.println("Hello Thread!");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        Thread thread = new Thread(runnable);
        thread.start();
        while(true){
            System.out.println("Hello main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

上述创建的线程和主线程都是独立运行在cpu上的,要想查看线程的运行状态以及详细信息,可以使用JDK中自带的一个工具jconsole;

thread的方法不能是run,只能是start,因为如果调用run方法的话thread中的run方法还是在主线程上运行的,start方法是线程的入口方法

4.3Thread中的其他方法

Java针对Thread类也提供了一系列方法来帮助程序员可以更好的控制程序:

复制代码
java 复制代码
package Thread;
//创建一个线程,并且给线程起一个名字
//实验isDaemon和setDaemon方法
//实验isAlive方法
public class Demo6 {
    public static void main(String[] args) {
        Thread thread = new Thread(() ->{
            while(true){
                System.out.println("Hello Thread!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
        },"张三的线程");//可以从这里直接起名字,也可以在后面设置线程名字
        System.out.println("线程是否存活:"+thread.isAlive());
        System.out.println("线程是否为后台线程:"+thread.isDaemon());
        thread.setDaemon(true);
        System.out.println("线程是否为后台线程:"+thread.isDaemon());
        //设置线程名字可以在start方法之前,也可以在start方法之后
        //但是设置线程是否为后台线程必须在start方法之前
        // thread.setName("张三的线程");
        thread.start();
        // thread.setName("张三的线程");
        System.out.println("线程是否存活:"+thread.isAlive());
        while(true){
            System.out.println("Hello main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

4.4Thread中的常见属性

Thread中的这些属性也可以帮助程序员更好的了解线程的基本情况

4.5线程的状态

线程的核心状态可以分为两种,一种是就绪状态,另一种是阻塞状态,Java还将阻塞状态分为了三种:即WAITING、TIMED_WAITINGBLOCKING 三个状态。阻塞状态和就绪状态可以相互转换

java 复制代码
package Thread;

public class Demo15 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (true) {
                System.out.println("Hello Thread!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        System.out.println(thread.getState()+"线程是否存活:"+thread.isAlive());
        thread.start();
        System.out.println(thread.getState() + "线程是否存活:" + thread.isAlive());
        //如果没有join方法,主线程是TIMED_WAITING状态
        //如果有join方法,主线程是WAITING状态
        thread.join();

        //此时线程执行完毕,线程状态是TERMINATED
        System.out.println(thread.getState() + "线程是否存活:" + thread.isAlive());
    }
}

4.6后台线程和前台线程

后台线程(又叫做守护线程) 就是假设这个线程没运行结束,但是主线程和其他线程都已经运行结束了,那么这个程序也就结束了,它无法阻止程序的结束 ,相当于一个在背后默默付出的人;而前台线程 则是这个线程没运行结束之前谁都无法结束程序,它可以阻止程序的结束,必须要等它运行结束才可以结束,相当于是一个比较关键的人。

线程的存活状态,若isAlive为真,那么其就是正在运行 ;如果为假,其要么准备运行,要么已经运行结束了

4.7中断一个线程

在多线程中,要想使线程结束正常来说只能等到其自己运行结束,但是有时候需要提前结束线程,这时候就有两种方法:

1.通过变量结束线程;

java 复制代码
public class Demo9 {
    private static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (flag) {
                System.out.println("Hello Thread!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        thread.start();
        System.out.println("Hello main");
        System.out.println(thread.isInterrupted());
        Thread.sleep(3000);
        flag = false;
        System.out.println("线程t已被终止");
        System.out.println(thread.isInterrupted());
    }
}

2.直接使用线程内置的标志位(isInterruptted)。

java 复制代码
public class Demo10 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            // currentThread()方法是一个Thread类中的静态方法,在哪个线程中调用它,就返回谁的线程对象
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("Hello Thread!");
                try {
                    //这里t线程中存在sleep方法,isInterrupted()返回的还是true,但是sleep被提前唤醒,isInterrupted就会继续变回false
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    System.out.println("线程被中断了");
                    //如果没有手动结束线程,那么线程就会一直持续下去
                    //这里可以添加一些其他逻辑,完成线程唤醒抛出异常之后的善后工作
                    break;
                }
            }
        });
        thread.start();
        Thread.sleep(4000);
        // System.out.println(thread.isInterrupted());
        // 这里尝试中断线程
        thread.interrupt();
    }
}

1.这里的flag可以作为结束线程thread的变量,因为thread和main线程是并发执行的,因此当main执行到flag=false时thread就会因为不满足循环条件而跳出循环从而结束线程;

2.这里有一个很奇怪的设定,就是当thread中使用sleep方法时,如果其他线程调用interrupt方法尝试中断线程时,他会将sleep提前唤醒,sleep就会抛出异常,之后会将isInterruptted置为false,这样可以给程序员更大的操作空间。

其实sleep方法的本质就是将一个线程设置为阻塞状态,在阻塞期间不参与任何cpu的调度,等到时间再将其唤醒恢复成就绪等待cpu的调度,sleep在实际工作中要慎用。

4.8线程等待

操作系统的线程调度是随机的,但是程序员不喜欢随机,想要线程有秩序的运行,这里就用到了线程等待(阻塞)这样一个概念。

线程等待有两种,一种是调用join方法,但是这种是死等 ,无法设置等待时间;另一种就是调用另一个重载的join方法,这种方法可以设置最长等待时间,单位是ms。

java 复制代码
public class Demo11 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (true) {
                System.out.println("Hello Thread!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    System.out.println("线程被中断了");
                    break;
                }
            }
        });
        thread.start();
        System.out.println("线程等待之前");
        //jion方法的意思就是等待,哪个线程中调用,谁就是等待的一方
        //哪个对象调用了它,哪个对象所指的线程就是被等待的一方
        thread.join();
        System.out.println("线程等待之后");
    }
}

join方法并不是一定要等待的,假如在main线程调用join方法之前thread就已经执行完毕,那么join方法调用之后不会产生任何阻塞。

二、线程安全问题

1.一般的线程安全问题

在编写Java代码的过程中,有些代码在单线程中没有问题,但是多线程中则会存在线程安全问题。

java 复制代码
public class Demo16 {
//这里如果将count变为局部变量,那么编译都过不了,因为局部变量会触发lambda表达式的变量捕获
//lambda表达式捕获的变量要求被final修饰或者事实final的
//写成成员变量则可以绕过变量捕获变成内部类访问外部类成员
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        thread1.start();
        thread2.start();
        
        //要想使线程安全,可以将线程上锁,也就是将操作变成原子的
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

在上述代码中,我们期望count的值是100000,但是结果不是100000,说明这个代码线程不安全,这是因为count++这个操作可以看作三步,首先是load(从内存中读取数据)、其次是add(将这个数加1)、最后再save(将新的结果放到内存中),这个操作放到单线程是没有任何问题的,但是放到多线程就会产生问题;

设想一下,如果线程thread1在load之后thread2紧接着执行一次,那么这两个线程虽然加了两次,但是内存中只会显示加一次之后的结果,同理,这个在thread2执行期间thread1也可能会来凑热闹,并且不止一次,这样count最后的值甚至可能小于50000。

解决办法:

要想解决这个线程安全问题,我们可以在线程内部for循环内部逻辑或者整个for循环上加上一把锁。

java 复制代码
public class Demo17 {
    //这里count不能够放到main方法内部作为成员变量,因为这会触发变量捕获,无法编译成功
    private static int count = 0;
    //这里的锁对象必须是静态的,因为静态方法不能访问非静态的成员变量
    private static Object locker = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                //加锁操作还可以修饰普通方法,这样就省略了锁对象
                synchronized (locker) {
                    count++;
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                //这里的synchronized加锁建议加到循环内,因为这样可以更充分的利用多核心的并行计算能力
                synchronized(locker){
                    count++;
                }
            }
        });
        thread1.start();
        thread2.start();
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(count);
    }
}

2.出现线程安全问题的根本原因

出现线程安全问题的原因主要有五点:

1.首先是线程调度是随机的(抢占式执行) ,这也是线程安全问题产生的根本原因

2.多个线程 针对同一个变量 进行修改操作;

3.修改操作不是原子的(不可再分)

4.内存可见性;

5.指令重排序。

3.加锁

在上面线程安全问题中,我们提到了要想解决上面的线程安全问题可以使用一把锁将线程内部的操作锁起来,其实加锁的本质就是将非原子的操作打包成一个整体 来达到"原子性"的效果,这里加锁用到了synchronized 关键字,加锁的核心就是加锁解锁,加锁只会影响其他加同一把锁的线程。

java 复制代码
synchronized(){//()内部需要一个锁对象,具体是什么锁不重要,只要是同一个对象就可以产生锁竞争
//进入到这里就是加锁
//执行代码逻辑......
}//从这里出去就是解锁

这样在load之前就多了一个lock的步骤,thread1先lock之后thread2再想lock就只能等待thread1先解锁,但是加锁不成功也不是加锁失败,而是一直等待,等到thread1解锁之后再和thread重新竞争加锁机会。

java 复制代码
Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
        });

上面的加锁方式是我们推荐的,这样可以最大程度的利用到cpu上的资源。

java 复制代码
Thread thread1 = new Thread(() -> {            
           synchronized (locker) {
                for (int i = 0; i < 50000; i++) {
                    count++;
                }
            }
        });

这种加锁方式是不推荐的,这样线程执行是串行的,就类似于单线程运行了,因为count++操作很简单,但是加锁和解锁也需要消耗资源,因此这种加锁方式运行需要的时间比上面少,如果将count++换为更复杂的逻辑,那么就是上面得枷锁方式运行需要的时间更少。

4.synchronized关键字

4.1synchronized的使用方法

在前面提到过用synchronized关键字来加锁,synchronized还有一些其他的使用方法。

1.synchronized加锁,这是synchronized的基本用法,不过它不是禁止线程调度,而是不让其他加同一把锁的线程插队。

java 复制代码
synchronized(锁对象){}

2.synchronized修饰普通方法,就可以省略锁对象,相当于锁对象是this。

java 复制代码
package Thread;

public class Demo100 {
    static class Counter {
        private int count = 0;
        // private Object locker = new Object();
        synchronized public void add() {
            synchronized (this) {
                count++;
            }
        }
        public int getCount() {
            return count;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(counter.getCount());
    }
    
}

在上面的代码中,两个synchronized的作用是一样的,都是针对Counter类进行加锁,取一个即可,在加锁时,锁对象是谁不重要,重点在于是不是一个针对同一个对象进行加锁。

3.synchronized修饰静态方法

java 复制代码
package Thread;

public class Demo100 {
    static class Counter {
        private static int count = 0;
        synchronized public static void add() {
            count++;
        }
        public static int getCount() {
            return count;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                Counter.add();
            }
        });
        
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                Counter.add();
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(Counter.getCount());
    }
}

此时就相当于针对Counter这个类进行加锁

4.2synchronized锁的特点

1.可重入

synchronized锁具有可重入的特性,可重入就是说如果一个线程针对一个锁连续加锁两次,这个锁会有一个类似于计数器的东西记录和判断什么时候需要解锁,什么时候不可以解锁。

在其他不具备可重入特点的锁中,如果一个线程对一把锁连续加锁两次 ,因为第一次加锁未解锁,所以会导致第二次加锁时触发锁竞争,使得锁变成了死锁 ;如果这个线程针对这把锁连续加锁两次不会触发锁冲突,这样的锁就被称作"可重入锁"。

死锁有很多形式,可重入只能解决其中的一种形式。

2.互斥

synchronized锁具有互斥 的特性,互斥也就是说如果在线程1已经使用synchronized锁通过locker加锁之后,在线程1没有释放锁时 线程2想要再通过locker对象使用synchronized加锁,就会产生阻塞,也就是常说的"一山不容二虎"。

5.死锁

5.1死锁的概念

在多线程编程中,在线程安全问题上往往都需要加锁来解决,但是如果锁用的不好的话很容易造成新的问题---死锁 ;假如说有两个人在吃饭,桌子上有一瓶饮料一瓶水 ,A手里拿着水但是他想要喝饮料,B手里拿着饮料但他想要喝水,他们俩都想要对方手里的东西,但是又都不想丢掉自己手里的东西;类似于这样两个线程同时占有各自的锁但是在未解锁的情况下还想要使用对方的锁导致无限等待下去,以至于程序无法运行的情况 就叫做死锁

5.2死锁的三种场景

1.一把锁针对一个线程同时加锁两次,如果这个锁没有可重入的特性,那么就会造成死锁。

2.两个线程两把锁,两个线程各自占有一把锁,未解锁时都想要获取对方的锁。

3.N个线程M把锁,也会造成死锁问题。

典型的案例就是哲学家就餐问题

假设有五位哲学家,他们在一张桌子上吃饭,他们每个人左右手都有一根筷子,他们想要吃饭就必须拿起左右手的筷子,在一般情况下肯定是最多有两个人拿到了两根筷子开始吃饭,吃完后放回去再由其他人拿走吃饭,但是在极端情况下每个人都拿到了左手边的筷子,他们再想要拿右手边的筷子时都拿不到,只能等他右手边的人吃完才可以获取筷子,这样就会一直等下去,这就造成了死锁。

5.3死锁的必要条件以及如何避免死锁

在编程工作中,死锁是客观存在的,而且有些bug是概率发生的,这就需要尽量避免发生死锁的现象,要想避免就需要了解死锁的必要条件。

1.锁是互斥的;

2.锁不可抢占;

3.请求和保持,在第一把锁未释放时就尝试获取第二把锁;

4.循环等待:就是等待锁释放的顺序构成了循环。

其中前两个是synchronized锁自带的特性,我们是无法更改的,只能从后两个入手,其中针对请求和保持的解决办法就是在线程执行过程中要申请另一把锁就需要先将自己的这把锁释放掉(有些场景也需要在锁1未释放的前提下获取锁2);针对循环等待就可以针对锁进行编号,规定其按照一定的顺序来执行(比如按照编号从小到大)。

java 复制代码
public class Demo19 {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();

    public static void main(String[] args) throws InterruptedException {
        //这里由于thread1和thread2都需要获取locker1和locker2,
        // 而thread1和thread2拿到对应锁之后没有解锁就直接要获取另外一个锁对象
        //这就会导致两个线程谁都无法获取对方的锁,这就导致了死锁
        Thread thread1 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("线程thread1获取了锁locker1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
                synchronized (locker2) {
                    System.out.println("线程thread1获取了锁locker2");
                }
            }
        });
        //死锁的四个必要条件:
        //1.锁互斥
        //2.锁不可抢占
        //3.请求和保持
        //4.循环等待
        //要想解决当前死锁,有两种方法,针对3可以将thread1和thread2先释放锁之后在获取另外一个锁
        //针对4可以将thread1和thread2的锁获取顺序统一起来,比如都先获取locker1再获取locker2
        Thread thread2 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("线程thread2获取了锁locker1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
                synchronized (locker2) {
                    System.out.println("线程thread2获取了锁locker2");
                }
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("线程thread1和线程thread2执行完毕");
    }
}

Java标准库中很多都是线程不安全的,例如我们常见的ArrayList、LinkedList、HashMap、TreeMap、HashSet、TreeSet以及StringBuilder等都是线程不安全的,但是也有线程安全的,例如:Vector、HashTable、ConcurrentHashMap以及StringBuffer等。

加锁是有代价的,虽然Java程序员不是特别注重性能,但是锁也不是随便加的。

6.内存可见性引发的线程安全问题

下面有一串代码:

java 复制代码
import java.util.Scanner;

//内存可见性引起的线程安全问题
public class Demo20 {
    //volatile可以解决内存可见性带来的线程问题,但是不能解决原子性问题
    private static volatile int flag = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            while (flag == 0) {
                //什么都不做或者做一些消耗时间比从内存中读取flag少的操作
            }
            System.out.println("thread1线程执行完毕");
        });
        Thread thread2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个数字作为flag的值:");
            int input = scanner.nextInt();
            flag = input;
            System.out.println("thread2线程执行完毕");
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}

在这个代码中,如果没有volatile关键字修饰flag ,那么运行出来的结果就是当你输入一个非0值作为flag的值时,这个程序会卡在thread1从而形成死循环 ,这是因为编译器优化机制 的存在,编译器优化机制就是说,flag读取几次之后编译器认为flag不会改变,程序会省略从内存中读取数据的过程转为直接读取寄存器上的数据,因此就无法获取thread2中对flag的修改的信息,要想解决这个问题,可以利用volatile关键字修饰flag常量 ;此时volitale的作用就是给编译器提了个醒,告诉它这个变量可能会修改,不需要优化,相当于在读取volatile变量前后加上了一个"内存屏障相关的指令",这样就避免了陷入死循环。

关于编译器优化:这是很多主流语言都会存在的机制,那些设计语言的大佬们会考虑到编程不只有高手,更多的是菜鸡,他们在编译编写代码时往往有一些不合理的地方,这种机制就能够在不影响编程逻辑的前提下对代码做出一些优化,不过在一些特殊情况下也会产生误判从而导致编程逻辑改变。

其实关于编译器优化的机制官方给出的解释是一个Java进程中存在着两块空间,一块是主内存 ,另一块是工作内存,按理说上面thread1读取flag的值是需要从主内存中先读取到工作内存然后再从工作内存中读取数据,但是经过多次循环之后发现这个值没变,这时候就会直接从工作内存中读取数据,此时修改flag变量修改的就是主内存的值,自然就影响不到工作内存了。

主内存 也就是我们常说的内存工作内存 就是寄存器和cpu缓存的统称

7.wait-notify机制

wait-notify是一对配合使用的方法,目的是为了协调线程之间的执行顺序 ;因为线程调度是随机的,而join只能控制线程的结束顺序,因此就需要用到这一对方法。例如:有线程1和线程2两个线程,希望线程1先执行线程2后执行,就可以让线程2通过wait主动阻塞 ,让线程1先执行,等到线程1执行完毕再在线程1中调用notify来唤醒线程2。

另外,wait-notify也能解决"线程饿死"问题

线程饿死就是说当线程1释放锁之后,其他线程就可以参与到锁竞争当中,此时线程1同样也可以,但是其他线程需要先等cpu唤醒才可以参与竞争,这样就使得线程1获取锁的概率很大 ,这样其他线程迟迟无法得到锁就是所谓的线程饿死

7.1wait-notify的使用方法

wait和notify方法都是要在锁内部使用的,默认wait是死等,也可以加上等待时间的上限,比如:

java 复制代码
public class Demo21 {
    private static Object locker = new Object();
    public static void main(String[] args) throws InterruptedException {
        synchronized (locker) {
            //这里的wait和sleep有些相似,不过wait只能在锁内使用;
            //并且锁对象要和synchronized的锁对象一致
            System.out.println("wait等待之前");
            locker.wait();//此处可以传入参数表示最多等多少毫秒
            System.out.println("wait等待之后");
        }
    }
}

在这里,wait做的事情就是首先释放锁并且使当前的代码进行等待,然后就是等待满足一定条件时被唤醒,重新尝试获取这个锁。

使用wait的时候,阻塞其实有两个阶段,一个是WAITING :通过wait等待其他线程通知;另一个是BLIOCKED,当收到通知后会重新尝试获取这把锁,但很可能又遇到锁竞争。

如果有多个线程都在进行wait等待(需要在一个对象上wait),那么调用notify就是随机唤醒一个线程notifyAll 则可以全部唤醒 ;不过凭空调用notify没有副作用,什么都不会发生。

java 复制代码
import java.util.Scanner;

public class Demo22 {
    private static Object locker = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            synchronized (locker) {
                System.out.println("thread1等待之前");
                try {
                    //这里wait也可以设置等待时间,单位是毫秒
                    locker.wait(3000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("thread1已经被唤醒了");
            }
        });
        Thread thread2 = new Thread(() ->{
            System.out.println("请输入任意内容唤醒thread1:");
        Scanner sc = new Scanner(System.in);
        sc.next();
        synchronized (locker) {
            locker.notify();
        }
        });
        thread1.start();
        thread2.start();
        // thread1.join();
        // thread2.join();
    }
}
java 复制代码
import java.util.Scanner;

public class Demo23 {
    private static Object locker = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            synchronized (locker) {
                System.out.println("thread1等待之前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
                System.out.println("thread1已经被唤醒了");
            }
        });
        Thread thread2 = new Thread(() ->{
            synchronized (locker) {
                System.out.println("thread2等待之前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
                System.out.println("thread2已经被唤醒了");
            }
        });
        Thread thread3 = new Thread(() ->{
            synchronized (locker) {
                System.out.println("thread3等待之前");
                try {
                    // 这里wait也可以设置等待时间,单位是毫秒
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("thread3已经被唤醒了");
            }
        });
        Thread thread4 = new Thread(() ->{
        System.out.println("请输入任意内容唤醒一个线程:");
        Scanner sc = new Scanner(System.in);
        sc.next();
            synchronized (locker) {
            //这里notify可以随机唤醒一个线程,如果之前没有wait操作直接调用notify方法,不会有任何效果
            // locker.notify();
            //这里notifyAll可以唤醒所有等待的线程
            locker.notifyAll();
        }
        });
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
}

7.2sleep和wait的区别(经典面试题)

1.首先wait被设计出来就是为了被提前唤醒的,超时时间是后手;而sleep设计出来就是为了到时间唤醒,虽然也可以通过interrupt提前唤醒,但是这样的唤醒是会产生异常的(程序出现不符合预期的情况才会称为"异常")。

2.wait需要搭配锁来使用,wait执行时会先释放掉锁;而sleep不需要搭配锁使用,当把sleep放到synchronized内部时,不会释放锁。(一个松开锁睡觉,另一个抱着锁睡觉)。

8.指令重排序引发的线程安全问题

指令重排序引发线程安全问题的根本原因在于编译器存在着一种优化机制,例如这里有一个懒汉模式的简单构造:

java 复制代码
class SingletonLazy {
    private static volatile SingletonLazy instance = null;
    static Object locker = new Object();
    public static SingletonLazy getInstance() {
            if (instance == null) {
                synchronized (locker) {
                    //这是指令重排序引发的线程安全问题
                    // (1)第一个问题是可能在多线程环境下,在if和new中间可能会被插队,此时就会new多个实例对象
                    // (2)第二个问题是,虽然加了锁,但是每次调用getInstance方法时都要加锁,效率较低
                    // (3)第三个问题是,由于指令重排序的存在,可能会导致2和3的顺序颠倒,因此需要加上volatile关键字
                    if (instance == null) {
                        instance = new SingletonLazy();
                    }
                }
        }
        return instance;
    }
    
}

从上述代码中我们可以看出这里的if判断与new对象这两个操作需要打包成一个原子,因此就需要加锁,但是不是每一次都需要加锁的,因此就需要在加锁的外面再放上一个if判断。

这样之后还不行,因为创建实例对象分三个步骤:1.分配内存空间2.初始化对象3.将instance指向分配的内存空间。

由于指令重排序的存在,可能会导致2和3的顺序颠倒 ,因此需要加上volatile关键字,这里应该意思就是在3执行完之后,其他线程调用getInstance方法,此时instance不为空,就不会触发锁竞争,这样其他线程使用到的instance指向的区域就是一堆垃圾数据。

三、多线程代码案例

1.单例模式

单例模式是一种设计模式,也就是说一个线程中只存在一个实例对象,虽然是一个类,但是语法角度上来说可以无限创建实例的,这个东西设计出来可以解决一些固定场景问题的,这些设计模式让编程菜鸡写的代码也可以比较靠谱,不过这是一个软要求,并不一定必须这么写。

1.1饿汉模式

饿汉模式,就是指在类加载时就马上创建实例对象,想要获取就直接return之前new好的对象。

java 复制代码
package Thread;
//单例模式,就是指一个进程(程序)中只存在一个实例对象
//这个模式是为了帮助程序员培养编码习惯

class SingletonHungry {
    public static SingletonHungry instance = new SingletonHungry();
    //1. 构造方法私有化
    private SingletonHungry() {

    }
    //2. 提供一个静态方法返回实例
    public static SingletonHungry getInstance() {
        return instance;
    }
}
public class Demo24 {
    public static void main(String[] args) {
        SingletonHungry singleton1 = SingletonHungry.getInstance();
        SingletonHungry singleton2 = SingletonHungry.getInstance();
        System.out.println(singleton1 == singleton2);
    }
}

1.2懒汉模式

懒汉模式与饿汉模式类似,都是在类中创建单个实例对象,不过不同之处在于懒汉模式比较"懒",它只有在需要使用的时候才创建实例对象,也就是"非必要不建实例"。

java 复制代码
class SingletonLazy {
    private static volatile SingletonLazy instance = null;
    static Object locker = new Object();
    public static SingletonLazy getInstance() {
        //懒汉模式,就是指在第一次调用getInstance方法时才创建实例对象,也就是"现上轿现扎耳朵眼"
            if (instance == null) {
                synchronized (locker) {
                    if (instance == null) {
                        instance = new SingletonLazy();
                    }
                }
        }
        return instance;
    }
}
public class Demo25 {
    public static void main(String[] args) {
        SingletonLazy singleton1 = SingletonLazy.getInstance();
        SingletonLazy singleton2 = SingletonLazy.getInstance();
        System.out.println(singleton1 == singleton2);
    }
}

这里为什么需要两个if并且加锁,而且还需要用volatile修饰instance,可以参考上面线程安全中指令重排序引发的线程安全问题。

2.阻塞队列

在多线程编程中,使用数据结构是不可避免的,但是绝大多数之前学过的数据结构都是线程不安全的 ,这里引入一种特殊的队列-阻塞队列 ,阻塞队列遵守普通队列**"先进先出"**的原则,相比普通队列多出了两个特性:

1.在队列为空时要是再想拿出元素就必须等待其他线程向队列中填入数据,否则就会处于阻塞状态;

2.在队列为满的时候,要是想再将数据放入队列中也需要等其他线程先取出数据,否则也会处于阻塞状态。

2.1生产者消费者模型

为了减少锁竞争,Java引入了生产者消费者模型,这个模型也有利于我们更好的了解阻塞队列。

举一个例子:假如说一家人包饺子,需要有人包、有人擀皮还要有人放锅里煮饺子,对于饺子皮这个对象来说,生产者就是擀皮的人,而消费者就是包饺子的人;而对于包好的饺子来说,包饺子的人则是生产者,煮饺子的人是消费者;因此生产者和消费者是一对相对的概念,对于不同的对象来说一个线程可能有不同的角色。

如果包饺子的人包的很快,擀皮的人跟不上,那包饺子的人就可以休息一下(触发阻塞);反之,擀皮的人很快那么饺子皮就会堆满桌子,这时候擀皮的人也会触发阻塞。

生产者消费者模型的好处:

1.减少资源竞争,提高运行效率;

2.可以让模块之间能够更好的解耦合。

2.2阻塞队列的优缺点

在我们以后的开发中,我们会经常使用到分布式系统,在公司中通常不只是单单一台服务器,而是开发一组服务器程序,这里就体现到使用阻塞队列的好处了。

1.首先,阻塞队列可以减少资源竞争,提高效率;

2.其次可以更好的解耦合;

3.最后是削峰填谷。

假如这是一个简单的网页访问程序,其中客户端需要给服务器发送请求,服务器A接收到请求后需要交给B审核,之后再交给C查询数据再逐层返回。

像这样ABC之间是直接调用的关系,他们之间的耦合程度就很大,一个修改其他两个也需要跟着修改,如果C这个模块修改了,那么B也要跟着修改,要是再增加一个D服务器与B连接,那么针对B也需要修改,这样就会十分麻烦,开发新业务的代价也会提高

要是在B和CD之间加上一个阻塞队列作为缓冲,那么C和D改变了对于B的影响也会很小。

而且假如某一个时间段有大量的用户访问,如果没有这个阻塞队列,服务器BCD承受的压力是相同的,那么复杂逻辑的服务器很有可能会直接崩溃 ,造成损失,有了阻塞队列作为缓冲,就相当于是把CD两个服务器保护了起来,这样在访问量多的时候可以控制C和D的压力,访问量少的时候也可以更充分利用C和D

当然,阻塞队列也不是越多越好,它的缺点(也是生产者消费者模型的缺点)就在于首先引入它会使系统更加复杂 ,维护起来更困难;其次就是引入的层数太多会增加网络资源的开销

2.3简单使用阻塞队列

java 复制代码
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class Demo27 {
    public static void main(String[] args) throws InterruptedException {
        //阻塞队列是线程安全的,它的put方法和take方法都是原子操作
        BlockingQueue<String> queue = new LinkedBlockingQueue<>(100);
        Thread producer = 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 consumer = new Thread(() -> {
            while (true) {
                try {
                    System.out.println("获取一个元素" + queue.take());
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        producer.start();
        consumer.start();
        producer.join();
        consumer.join();
    }
}

2.4自己手动实现一个阻塞队列

java 复制代码
package Thread;
//基于数组手动实现一个阻塞队列
class MyBlockingQueue {
    private int size = 0;
    private String[] array = null;
    private int head = 0;
    private int tail = 0;
    private Object locker = new Object();

    MyBlockingQueue(int capacity) {
        array = new String[capacity];
    }

    //入队列
    public void put(String elem) throws InterruptedException {
        synchronized (locker) {
            //入队列时,需要判断队列是否已满
            while (size >= array.length) {//这里用while是因为wait可能被其他非take方法唤醒
                locker.wait();
            }
            array[tail] = elem;
            tail++;
            if (tail >= array.length) {
                tail = 0;
            }
            size++;
            locker.notify();
        }
        
    }

    public String take() throws InterruptedException {
        synchronized (locker) {
            //出队列时,需要判断队列是否为空
            while (size <= 0) {//同理,这里用while是因为wait可能被其他非put方法唤醒
                locker.wait();
            }
            String result = array[head];
            head++;
            if (head >= array.length) {
                head = 0;
            }
            size--;
            locker.notify();
            return result;
        }
    }
}

public class Demo28 {
    public static void main(String[] args) {
        
        MyBlockingQueue queue = new MyBlockingQueue(100);
        Thread producer = new Thread(() -> {
            int count = 0;
            try {
                while (true) {
                    queue.put("" + count);
                    System.out.println("生产一个元素" + count);
                    count++;
                    // Thread.sleep(1000);
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        Thread consumer = new Thread(() -> {
            while (true) {
                try {
                    System.out.println("获取一个元素" + queue.take());
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        producer.start();
        consumer.start();
    }
}

3.线程池

池这个概念之前提到过,它是存放常量的一种数据结构 ,在这块空间中的常量可以随意取用,因此两个常量字符串的地址都是相同的,而这种存放线程的池 就叫做线程池;之所以需要有这个常量池,是因为线程的创建和销毁都是需要资源的,为了节省资源,就会提前将线程创建好放在线程池中以便直接使用。

3.1标准库中线程池的简单使用

线程池的方法名为:

java 复制代码
​
ThreadPoolExecutor(int corePoolSize, 
int maximumPoolSize, 
long keepAliveTime, 
TimeUnit unit, 
BlockingQueue<Runnable> workQueue, 
ThreadFactory threadFactory, 
RejectedExecutionHandler handler);

​

其中各个参数的含义为:

1.int corePoolSize:核心线程数;

2.int maximumPoolSize:最大线程数;

3.long keepAliveTime:临时线程允许存活的最长时间;

4.TimeUnit unit:临时线程允许摸鱼的最大时间;

5.BlockingQueue<Runnable> workQueue:存放要执行的任务的队列;

6.ThreadFactory threadFactory: 线程工厂,用来创建临时线程; 7.RejectedExecutionHandler handler:拒绝策略(面试重点)。

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Demo30 {
    public static void main(String[] args) {
        //这里是创建一个固定大小的线程池,线程池中的线程全部都是核心线程,有4个线程
        ExecutorService service = Executors.newFixedThreadPool(4);
        //这里是创建一个全部都是由临时线程组成的线程池,线程的最大个数为Integer.MAX_VALUE
        ExecutorService service2 = Executors.newCachedThreadPool();
        //该线程池是只有一个线程的线程池,这个用的不多
        ExecutorService service3 = Executors.newSingleThreadExecutor();
        //这是一个定时器,用来调整线程池中任务的执行顺序
        ExecutorService service4 = Executors.newScheduledThreadPool(4);
        for (int i = 0; i < 100; i++) {
            int id = i;
            service.submit(new Runnable() {
                @Override
                public void run() {
                    //这里实际执行的结果不是按照我们设置的顺序来进行的,可以看出线程调度是随机的
                    System.out.println("执行了一个任务:" + id);//这里不用i是因为会触发捕获变量
                }
            });
        }
    }
}

3.2工厂设计模式

正常创建一个类是通过构造方法创建的,不过构造方法存在缺陷,例如下面代码中两种点的表示方法都需要相同的参数,不过其中表示的内涵不同,这样就无法重载这个方法,这时候工厂设计模式就可以填补这样的缺陷,在工厂类中使用方法构造Point类的不同表示。

java 复制代码
package Thread;

class Point {
    // public Point(double x, double y) {
    //     //初始化逻辑,用笛卡尔坐标系表示点的位置
    // }
    // public Point(double r,double a){
    //     //初始化逻辑,用极坐标系表示点的位置
    // }
    //这里说明构造方法重载是有缺陷的,因为它需要能根据参数列表的不同来区分不同的构造方法
}

//用来构造Point的类,也被叫做"工厂类"
class PointFactory {
    //工厂类也就是将构造方法初始化换成用静态方法来初始化
    public static Point buildPointByXY(double x, double y) {
        Point point = new Point();
        return point;
    }
    public static Point buildPointByRA(double r, double a) {
        return new Point();
    }
}

public class Demo29 {
    public static void main(String[] args) {
        Point point = PointFactory.buildPointByXY(1.0, 2.0);
        Point point2 = PointFactory.buildPointByRA(1.0, 2.0);
    }
}

3.3拒绝策略

这里的拒绝策略就是指假如说我们存放任务队列的阻塞队列满了,此时又有一个新的任务来了怎么办,一般来说应该是阻塞,但是我们实际编程中除非特殊说明,否则一般不希望存在这些突发性的阻塞,这时就需要我们程序员自己手动设置任务队列满了之后再来新任务的对策。

比如说智能驾驶,如果在任务队列满了时来了一个检测障碍物的任务来了,一旦阻塞那就直接G了。

ThreadPoolExecutor提供了四种拒绝策略:

java 复制代码
ThreadPoolExecutor.AbortPolicy:程序会直接终止并且抛出一个异常;
ThreadPoolExecutor.CallerRunsPolicy:由调用者自己负责解决;
ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列中最老的任务;
ThreadPoolExecutor.DiscardPolicy:丢弃队列中最新的任务,也就是最新的任务。

例如:张三一天有四节课,上下午各两节,这天李四叫张三去网吧,第一种就是张三听说要去网吧但是课程很满没时间,网吧也不去了,直接崩溃了;第二种则是让李四自己去上网吧;第三种则是把第一节课给旷了;最后这种则是把第四节课给旷了。

Java标准库中也提供了一个简化版本的线程池Executor,它可以直接调用方法来创建不同类型的线程池,它的本质上是对ThreadPoolExecutor进行了封装,不过有些公司依旧坚持使用完整的ThreadPoolExecutor,因为它的可控性更强。

3.4手动实现一个线程池

java 复制代码
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

//实现一个自己的线程池
class MyFixedThreadPool {
    //线程池中需要有一个阻塞队列,用来存放线程任务
    //这里用链表是因为链表的插入和删除效率比较高
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue();

    public MyFixedThreadPool(int num) {
        for (int i = 0; i < num; i++) {
            Thread thread = new Thread(() -> {
                try {
                    //线程中需要将任务从任务队列中取出并且执行,如果为空,那么就会产生阻塞
                    Runnable task = queue.take();
                    task.run();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread.start();
        }
    }
    //向线程池中添加一个任务
    public void submit(Runnable task) throws InterruptedException {
        queue.put(task);
    }
}

public class Demo31 {
    public static void main(String[] args) throws InterruptedException {
        MyFixedThreadPool pool = new MyFixedThreadPool(100);
        for (int i = 0; i < 100; i++) {
            int id = i;
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("执行一个任务:" + id);
                }
                
            });
        }
    }
}

这里的思路就是首先我们需要一个阻塞队列 作为存储任务 的队列,用链表是因为链表的插入删除比较方便 ,之后再通过构造方法构造num个线程,线程里的逻辑就是先从任务队列中取出并且执行,如果为空,那么就会产生阻塞,最后再启动线程;之后再实现一个submit方法用来向任务队列中添加任务。

4.定时器

定时器是编程工作中一个很重要的组件,它和阻塞队列一样,都会被封装成一个/一组服务器中,这个定时器类似于一个闹钟,它会在一定时间之后去执行一些特定的任务,Java标准库就提供了一个Timer类的简单定时器。

定时器可以做一些周期性的工作,例如Java的垃圾回收机制。

4.1简单使用定时器

java 复制代码
import java.util.Timer;
import java.util.TimerTask;
public class Demo32 {
    public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("定时任务执行了: 3000");
            }
        }, 3000);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("定时任务执行了: 2000");
            }
        }, 2000);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("定时任务执行了: 1000");
            }
        }, 1000);
    }
}

需要注意的是,这里的三个任务虽然是按照时间先后打印出来的,但是这三个任务不是按照时间顺序开始执行的。

4.2手动实现一个定时器

java 复制代码
//手动实现一个定时器
import java.util.PriorityQueue;
class MyTimerTask implements Comparable<MyTimerTask>{
    private Runnable task;
    private long time;
    public MyTimerTask(Runnable task, long time) {
        this.task = task;
        //这里需要一个绝对时间戳方便与当前时间进行比较
        this.time = System.currentTimeMillis() + time;
    }
    public Runnable getTask() {
        return this.task;
    }
    public long getTime() {
        return this.time;
    }
    @Override
    public int compareTo(MyTimerTask o) {
        return (int) (this.time - o.getTime());
    }
}
class MyTimer {
    //首先需要一个优先级队列来存储和排序任务
    private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
    private Object locker = new Object();

    public MyTimer() {
        //创建线程,让这个线程查看是否到达时间执行任务
        //如果到达时间就出队列并且执行任务
        //如果没到就继续等待
        Thread thread = new Thread(() -> {
            while (true) {
                try {
                    //这里需要上锁,这里涉及到修改操作
                    synchronized (locker) {
                        // 判断堆是否为空
                        if (queue.isEmpty()) {
                            locker.wait();
                        }
                        MyTimerTask task = queue.peek();
                        // 再判断堆顶任务是否到达执行时间
                        long curtime = System.currentTimeMillis();
                        if (curtime < task.getTime()) {
                            locker.wait(task.getTime() - curtime);
                        } else {
                            // 执行run方法
                            task.getTask().run();
                            queue.poll();
                        }
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        thread.start();
    }
//然后需要一个方法来添加任务
public void schedule(Runnable runnable, long delay) {
    synchronized (locker) {
        queue.offer(new MyTimerTask(runnable, delay));
        locker.notify();
        }
    }
}
public class Demo33 {
    public static void main(String[] args) {
        MyTimer mytimer = new MyTimer();
        mytimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("执行一个任务: 3000");
            }
        }, 3000);
        mytimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("执行一个任务: 2000");
            }
        }, 2000);
        mytimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("执行一个任务: 1000");
            }
        }, 1000);
    }
}

四、锁策略

锁策略与死锁不同点在于锁策略是设计锁的思路或理念,但是死锁是指使用锁不当产生的bug。

1.乐观锁和悲观锁

乐观锁就是指认为线程阻塞的概率不大,遇到锁冲突时通常会采用一些其他方式(例如忙等、版本号等)来代替阻塞;悲观锁则是认为锁冲突的概率很高,它在遇到锁冲突时通常会采用阻塞的方式等待。

2.重量级锁和轻量级锁

重量级锁就是指加锁的成本比较大 ,等待锁的线程等待时间相对较长 ;轻量级锁则是指加锁的成本较小等待锁的时间相对较短

3.挂起等待锁和自旋锁

挂起等待锁是指当遇到锁冲突时会直接让线程挂起等待(调出cpu,等待cpu唤醒),是重量级锁和悲观锁的典型实现;自旋锁遇到锁冲突不会放弃cpu,而是通过"忙等"的方式再次尝试获取锁。

例如:玩一款游戏,这个游戏需要更新,挂起等待锁 就是指我认为这个游戏更新需要很长时间,我在这等着很浪费时间,不如去做一些其他事情,等到更新好了再回来;而自旋锁则是一直在手机旁盯着,一旦本轮更新完毕就马上尝试进入游戏。

synchronized锁则采用了一个折中的方式-自适应,这样既能保证不会太多浪费时间也能够保证效率,自适应就是指当锁竞争比较激烈时采用挂起等待锁的策略,当锁竞争压力比较小时采用自旋锁的策略。

4.公平锁和非公平锁

多个线程同时竞争一把锁时,当上一个线程使用结束后,这个锁给哪个线程呢?

公平分为两种:一种是先来后到,而另一种则是概率均等,我们这里的公平指的是先来后到,而概率均等则是非公平锁。

公平锁:

非公平锁:

5.可重入锁和不可重入锁

这个前面已经说到,synchronized属于可重入锁,也就是一个线程针对一个对象的synchronized锁可以连续加锁两次,synchronized会自己判断什么时候可以解锁,什么时候不可以

6.读写锁和互斥锁

synchronized就是互斥锁,锁冲突的产生就是从这里来的;读写锁把加锁分为两种,一种是加读锁,一种是加写锁,读锁和读锁之间不会产生互斥,只有读锁和写锁、写锁和写锁之间才会产生互斥,这样就降低了很多锁冲突带来的性能损失

五、synchronized优化机制

1.锁升级:

synchronized锁的工作过程一共分为四步,首先是无锁 ,这种情况就是没有锁竞争的情况下synchronized的机制;等到有很少量的锁竞争就会升级到偏向锁 ,偏向锁就是指在线程工作过程中给这个线程做一个标记用来替代加锁,等到有其他线程想要竞争锁时再抢先加锁;在有一定程度的锁竞争时,synchronized再升级为自旋锁 ;等到锁竞争压力大了,再升级为重量级锁。这也就是synchronized的锁升级策略。

在当前主流的JVM之下,锁升级的过程是不可逆的,也就是只能往重量级锁方向升级。

2.锁消除:

在当前的JVM中,有些地方如果你加了锁,但是JVM认为没必要加锁的话,即使你对线程加了锁,JVM也不会真的给线程加锁。

3.锁粗化:

锁粗化就是指假如有一段执行过程中一个锁需要多次加锁解锁,synchronized锁就会让这段过程中一直对该线程进行加锁,也就是将多次加锁解锁合并为一次,因为获取到一个锁是件不容易的事,因此这样就能减小资源开销,提高效率。

六、CAS(比较和交换)

1.CAS的典型应用

CAS是一种用来在多线程环境下实现原子操作的无锁同步机制。

下面是CAS的伪代码:

java 复制代码
boolean CAS(address, expectValue, swapValue) {
  if (&address == expectedValue) {
    &address = swapValue;//(交换address和swapValue的值)
    return true;
    }
        return false;
} 

这里的三个参数,第一个是指内存中的值,后两个都是指cpu寄存器中的值,这段代码会将内存中的值与寄存器1的值进行比较,如果相等那么就将内存中的值与寄存器2中的值进行交换,由于不关心寄存器中值的变化,所以可以近似的看成是赋值。

1.1实现原子类

原子类就是指上面操作只由一个Linux指令完成,这样就不会涉及到线程安全问题,下面是一些原子类的方法。

不过在c++、python等语言中,支持运算符重载,也就是指可以让程序员自己设计一个运算符,让这个运算符表示一些其他的含义,比如定义一个类:"复数",通过运算符重载,就可以使复数类和其他数字类型相似,都可以进行加减乘除,运算符重载可以使代码的一致性更高,而且不仅线程安全,而且效率更高,不涉及到"阻塞";但是Java认为这样可能会被滥用,因此Java语法中不支持运算符重载。

在实际开发中,我们在涉及到"计数"的需求,我们应该优先使用原子类而非加锁。

1.2实现自旋锁

自旋锁的本质就是自己转圈圈,当owner线程为null时,此时为解锁状态,否则就会保存持有锁的线程的引用;如果这个锁被占用了,那么就会快速反复循环,没有任何sleep,这里消耗cpu换来的是更快的加锁速度,锁竞争很激烈时,大量的线程都会这样自旋,此时cpu是负担不起的,就算这个锁释放,也还是有大量的线程拿不到锁,因此就会升级为重量级锁。

java 复制代码
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;  //这里天然就是原子的
  } 
} 

2.CAS的ABA问题

通过CAS来判定,如果当前load到寄存器的内容与内存中的内容一致,就认为当前这个线程没人修改,接下来这个线程的修改操作都是安全的,但是这里有一个缺陷,如果在CAS判断期间,有另一个线程将内存中的值从A改到B,再从B改到A,这样的话CAS是无法感知的。

2.1第一种极端情况:

张三去银行里存钱,他想取500块钱,机器突然卡顿,他于是又按了一次,此时产生了两个取钱的线程,在A线程从赋值到判断的过程中B线程取了500元,此时A这里并没有出现问题。

2.2第二种极端情况:

依旧是张三去取钱,还是机器卡顿产生两个线程,但是这次在张三第二个线程中取款之后紧接着李四此时给张三的账户汇了500元钱,此时张三的账户重新变为1000,满足扣500的条件,这时候就会多扣500块钱。

七、JUC

1.实现Callable接口

这里实现Callable接口创建线程的方法有点像俄罗斯套娃,首先创建Callable对象,在这个对象中写一个方法call,在这个方法中实现线程中的逻辑,接着创建一个FutureTask对象并且将callable对象作为参数传输过去,之后再创建Thread对象将FutureTask对象作为参数传给Thread,最后调用Thread中的start方法启动线程。

java 复制代码
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

//通过new一个Callable对象重写call方法来创建一个有返回值的线程
public class Demo36 {
    /**
     * @param args
     * @throws ExecutionException 
     * @throws InterruptedException 
     */
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        //Callable带有泛型参数,泛型参数就是返回值的类型
        Callable<Integer> callable = new Callable<Integer>() {

            @Override
            public Integer call() throws Exception {
                int result = 0;
                for (int i = 1; i <= 100; i++) {
                    result += i;
                }
                return result;
            }
            
        };
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread thread = new Thread(futureTask);
        thread.start();
        System.out.println(futureTask.get());
    }
}

2.ReentrantLock(可重入的)

ReentrantLock也是锁,只不过它比较传统,和synchronized相比,ReentrantLock是Java标准库中的一个类,它是在JVM外实现的,synchronized则是JVM内部实现的一个关键字。

java 复制代码
import java.util.concurrent.locks.ReentrantLock;

public class Demo37 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock locker = new ReentrantLock();
        Thread thread1 = new Thread(() -> {
            if (locker.tryLock()) {
                try{
                    count++;
                    System.out.println("加锁成功");
                } finally {
                    //加锁成功之后需要手动解锁
                    locker.unlock();
                }
            } else {
                //这里可以执行其他逻辑
                System.out.println("加锁失败");
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                locker.lock();
                count++;
                locker.unlock();
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}

ReentrantLock与synchronized的对比:

1.首先synchronized关键字在加锁之后不需要考虑解锁的问题,而ReentrantLock需要手动解锁,虽然更加灵活,但是容易遗漏unlock,造成资源泄露;

2.其次是ReentrantLock在尝试trylock遇到阻塞时不会死等,而是等待一段时间后如果还没有获取到锁会直接放弃,而synchronized锁会一直死等;

3.ReentrantLock是非公平锁,synchronized是公平锁,不过ReentrantLock可以通过向构造方法传入一个true来开启公平锁模式;

4.ReentrantLock具有更强大的唤醒机制,synchronized锁是通过wait-notify机制来唤醒,如果有多个wait的话唤醒是随机的,但是ReentrantLock是搭配Condition类实现等待-唤醒的,可以精确地控制唤醒哪个线程。

3.Semaphore(信号量)

Semaphore也是Java库中内置的一个类,它的本质就是一个计数器,类似于停车场的计数器有N个空闲车位,每当有一个车进入停车场时就申请资源(P操作)N-1,出去时则释放资源(V操作)N+1,如果停车场满了,再进车辆就会触发阻塞,不让外面的车辆进入。

java 复制代码
import java.util.concurrent.Semaphore;

public class Demo38 {
    public static void main(String[] args) throws InterruptedException {
        //这里参数的意思是初始可以申请资源的数量
        Semaphore semaphore = new Semaphore(3);
        //如果没有可以释放的资源,release方法就会增加一个可以申请资源的数量
        semaphore.release();
        System.out.println("执行了一次V操作");
        semaphore.acquire();//相当于P操作
        System.out.println("执行了一次P操作");
        semaphore.acquire();
        System.out.println("执行了第二次P操作");
        semaphore.acquire();
        System.out.println("执行了第三次P操作");
        // semaphore.release();//相当于V操作
        // System.out.println("执行了一次V操作");
        //这里会发生阻塞,需要先进行V操作,才能继续申请资源 
        semaphore.acquire();
        System.out.println("执行了第四次P操作");
        // semaphore.release();//相当于V操作
        // System.out.println("执行了一次V操作");
    }
}

利用Semaphore对多线程进行加锁:

java 复制代码
import java.util.concurrent.Semaphore;
public class Demo39 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(1);
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                try {
                    //相当于进行了一次加锁
                    semaphore.acquire();
                    count++;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    //相当于进行了一次解锁
                    semaphore.release();
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                try {
                    semaphore.acquire();
                    count++;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    semaphore.release();
                }
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

这里每次P操作都是进行一次加锁,每次V操作都是一次解锁。

在编写线程安全的代码时,一共有三种方法:加锁(最常用)、CAS/原子类、信号量

4.CountDownLatch(锁存器)

在开发中,会涉及到一个大的任务被分成多个小的任务通过多线程来完成,只有全部完成了才能进入下一个阶段,这里的CountDownLatch就是这个原理。

java 复制代码
import java.util.concurrent.CountDownLatch;

public class Demo40 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(8);
        for (int i = 1; i <= 8; i++) {
            int id = i;
            Thread thread = new Thread(() -> {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("第" + id + "个线程执行完毕");
                countDownLatch.countDown();//每个线程执行完毕后,计数器减一
            });
            thread.start();
        }
        //countDownLatch通过await方法等待所有的线程都执行完毕且计数器减一,直到计数器为0
        countDownLatch.await();//等待所有线程执行完毕
        System.out.println("所有线程执行完毕");
    }
}

5.在多线程情况下如何使用哈希表?(高频面试题)

在这之前我们说过,我们常用的数据结构大多数都是线程不安全的,因此想要在多线程情况下使用哈希表,这里有三个方法:

1.自己加锁;

2.使用HashTable,这类似于Vector,它是在关键方法上加上了Synchronized,不过这个是JDK即将废弃的方案,前两个都不建议使用;

3.使用ConcurrentHashMap,这个方法最大的调整就是对锁的粒度进行了优化,它在给哈希表中上锁的时候不是将整个哈希表都给锁住了,当哈希表中满足一些条件时,哈希表中的数据就会转换为一个哈希表携带着无数条链表的形式,Hashtable是只要有线程对哈希表中的元素进行修改等操作,就会触发锁竞争, 而ConcurrentHashMap则是针对每条链表的表头进行加锁只有对同一条链表进行修改等操作,才会触发锁竞争

对于size,它会随着put和remove触发++和--,它的改变是基于CAS机制的,采用的是原子类方案。

关于ConcurrentHashMap的扩容机制,采取的是"化整为零"的策略,也就是只在一次搬运一点点,确保每次持有锁的时间足够短,一旦触发搬运,每次get、put、remove等都会搬运一点点

相关推荐
皙然2 小时前
深入浅出 JVM:从内存结构到性能调优的全维度解析
java·jvm
冬天豆腐2 小时前
Springcloud,Nacos管理,打jar包后,启动报错
java·spring cloud·maven·jar
redgxp2 小时前
SpringBoot3整合FastJSON2如何配置configureMessageConverters
java
空空kkk2 小时前
Java集合——List
java
telllong2 小时前
C++20 Modules:从入门到真香
java·前端·c++20
程序员小崔日记2 小时前
一道基础计算题卡在 40 分,求助判题规则问题
java·算法·竞赛
是Yu欸2 小时前
LangGraph 智能体状态管理与决策
java·javascript·数据库
计算机学姐2 小时前
基于SpringBoot的中药材店铺管理系统
java·vue.js·spring boot·后端·spring·tomcat·推荐算法
猫墨*2 小时前
springboot3、knife4j-openapi3配置动态接口版本管理
java·开发语言