多线程初阶(二)- 线程安全问题

目录

1.观察count++

原因总结

2.解决方案-synchronized关键字

(1)synchronized的特性

(2)如何正确使用

语法格式

3.死锁

(1)造成死锁的情况

(2)死锁的四个必要条件

4.Java标准库中的线程安全类

5.volatile关键字

(1)内存可见性问题

原因

解决方案

(2)不解决原子性问题

6.wait和notify

(1)wait()

(2)notify()

(3)线程饿死问题

7.wait和sleep的对比(面试题))


1.观察count++

我们观察以下代码:

java 复制代码
public class Demo20 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

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

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

        System.out.println(count);
    }
}

他的逻辑是将count在不同的线程下进行五万次++操作,理想的结果是100000,但由于是并发执行,结果并不能达到预期,每次的结果都不相同,因为多个线程并发执行,引起的bug
这样的bug称为""线程安全问题"或者叫做"线程不安全"

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是 线程安全的。


我们从cpu的视角来观察count++操作,它是由3个指令的:

  1. 把内存中的数据读取到cpu寄存器里 load
  2. 把cpu寄存器里的数据+1 add
  3. 把寄存器的值写回内存 save

由于CPU是随即调度,抢占式先行所以在调度线程的时候不知道什么时候会切换线程

指令是cpu执行的最基本单位,要调度,至少把当前执行完,不会执行一半调度走,所以当针对一条指令的时候就不会出现安全性问题;但是由于count++是三个指令,可能会出现cpu 执行了其中的1个指令或者2个指令或者3个指令调度走的情况,这样就会出现线程安全问题产生bug。

无bug的情况:

有bug的情况(出现了覆盖的状况):

原因总结

  1. 线程在操作系统中是随即调度,抢占式执行的(根本原因)
  2. 多个线程同时修改同一个变量
  3. 修改操作不是"原子"的
  4. 内存可见性问题
  5. 指令重排序问题

原子性:原子是不可分割的最小单位,cpu视角不可分割的最小单位就是一条指令,cpu在进行调度切换线程的时候势必会确保执行完一条指令才能调度走再执行下一条命令,所以像count++, +=,-=之类的操作都不具备原子性 赋值操作a=b是具备原子性的

2.解决方案-synchronized关键字

针对原因一我们无法干预,操作系统内核,负责的工作,咱们作为应用层的程序员,无法干预

针对原因二取决于实际的需求.有的场景能这么改,有的场景不能这么改取决于实际的需求

在Java中这个方案不算很普适的方案.

针对原因三我们重点进行探讨,该操作不是原子的那怎么可以变成原子的呢

进行加锁操作,想象一个上厕所的场景,你对门进行了加锁,这样别人就不能进来,只有当你上完厕所出来才算解锁

**注意:**此处的加锁操作并非是将count++操作变成原子的,也没有干预到线程的调度,只是通过这种加锁的方式来保证一个线程在执行count++操作的过程中其他线程的count++不能插队进来

(1)synchronized的特性

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

    进入synchronized修饰的代码块,相当于加锁
    退出synchronized修饰的代码块,相当于解锁
    synchronized用的锁是存在Java对象头里的。

  2. 可重入 :针对一个线程一把锁.这个线程针对这把锁,连续加锁两次这种情况理论上应该死锁,但由于该特性不会造成死锁

    在可重入锁的内部,包含了"线程持有者"和"计数器"两个信息.
    如果某个线程加锁的时候,发现锁已经被人占用,但是恰好占用的正是自己,那么仍然可以继续获取到锁,并让计数器自增.
    解锁的时候计数器递减为0的时候,才真正释放锁.(才能被别的线程获取到)

(2)如何正确使用

java 复制代码
synchronized() {

}

synchronized不是函数而是关键字,括号内也不是参数,而是用来指定一个锁对象(可以指定任何对象),通过锁对象来进行后续的判定

{}里面的代码,就是要打包到一起的代码~~

{}还可以放任意的其他代码,包括调用别的方法等合法的java代码
进入代码块就会进行加锁,出代码块就会进行解锁

java 复制代码
public class Demo21 {
    private static int count = 0;
    private static Object locker = new Object();
    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });

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

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

        System.out.println(count);
    }

代码解释:t1,t2针对同一个对象locker进行加锁,t1先进行加锁,执行代码块中的代码,此时t2进行等待,t1执行完毕后,t2进行加锁再执行该线程下的代码

(这两者的++操作,不会穿插执行了,也就不会相互覆盖掉对方的结果了)

本质上是把随机的并发执行过程,强制变成了串行,从而解决了刚才的线程安全问题
上述操作能够正确执行的原因是,两个线程都加锁了,并且针对的是同一个对象加锁了

以下两种情况就不能正确执行

  1. 只有一个线程加锁

    java 复制代码
    public class Demo21 {
        private static int count = 0;
        private static Object locker1 = new Object();
        private static Object locker2 = new Object();
        public static void main(String[] args) throws InterruptedException {
    
            Thread t1 = new Thread(() ->{
                for (int i = 0; i < 50000; i++) {
                    synchronized (locker1){
                        count++;
                    }
                }
            });
    
            Thread t2 = new Thread(() ->{
                for (int i = 0; i < 50000; i++) {
                    synchronized (locker2){
                        count++;
                    }
                }
            });
          
            t1.start();
            t2.start();
    
    
            t1.join();
            t2.join();
    
            System.out.println(count);
        }
  2. 多线程针对不同的对象加锁

    java 复制代码
    private static int count = 0;
        private static Object locker1 = new Object();
        private static Object locker2 = new Object();
        public static void main(String[] args) throws InterruptedException {
    
            Thread t1 = new Thread(() ->{
                for (int i = 0; i < 50000; i++) {
                    synchronized (locker1){
                        count++;
                    }
                }
            });
    
            Thread t2 = new Thread(() ->{
                for (int i = 0; i < 50000; i++) {
                    synchronized (locker2){
                        count++;
                    }
                }
            });
            t1.start();
            t2.start();
    
            t1.join();
            t2.join();
    
            System.out.println(count);

以上情况为两个线程针对同一个对象加锁,当第一个线程解锁之后就会执行第二个线程进行加锁;如果是三个线程针对同一个对象加锁,当某个线程先加上锁,另外两个线程开始阻塞等待,此时这两个线程谁先拿到锁是无法预期的,但不存在线程安全问题

多个线程针对同一个对象加锁(大于2)

java 复制代码
public class Demo21 {
    private static int count = 0;
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();
    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker1){
                    count++;
                }
            }
        });

        Thread t2 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker2){
                    count++;
                }
            }
        });
        Thread t3 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker2){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t3.start();

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

        System.out.println(count);
    }

锁对象的作用 :用来区分多个线程是否针对"同一个对象"加锁,

是同一个就会发生"阻塞"(锁竞争/锁冲突)

不是同一个对象就不会发生阻塞,两个线程仍然是随即调度的并发执行
注意事项:

synchronized关键字本质上比join的串行执行,效率还是要高的

join的串行化是针对线程与线程之间,而synchronized关键字是针对线程中的一小部分逻辑进行加锁来实现串行化

语法格式

修饰类对象

在编写Java代码,本身是.java文件,通过javac编译成.class文件,jvm运行的时候把.class文件加载到内存中进而形成对应的类对象

一个 java进程中一个类的类对象只有唯一一个

java 复制代码
private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
                synchronized (Demo21.class){
                    count++;
                }

            }
        });

        Thread t2 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
                synchronized (Demo21.class){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();

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

        System.out.println(count);
    }

修饰普通方法

java 复制代码
class Counter {
    public int count = 0;
    public synchronized void add(){
        count++;
    }
}


class Counter {
    public int count = 0;
    public void add(){
        synchronized (this) {
            count++;
        }
    }
}

public class Demo23 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter1();
       
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter1.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter1.add();
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(Counter.count);
    }
}

修饰静态方法

java 复制代码
class Counter {
    public static int count = 0;
    public synchronized static void add(){
        count++;
    }
    
}

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

            }
        });

        Thread t2 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
                synchronized (Counter.class){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();

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

        System.out.println(count);
    }
}

3.死锁

(1)造成死锁的情况

  1. 一个线程一把锁.这个线程针对这把锁,连续加锁两次

    这个情况在代码实例中,并没有真的出现死锁,synchronized针对这个情况做了特殊处理synchronized是**"可重入锁"**
    针对上述一个线程连续加锁两次的情况做了特殊处理,只有第一次加锁生效,之后的加锁不会生效直接放行

    java 复制代码
    class Counter1{
        public static int count = 0;
        public void add(){
            synchronized (this) {
                synchronized (this) {
                    count++;
                }
    
            }
        }
    }

    那可重入锁是如何判断是否用加锁的情况呢?

  2. 两个线程两把锁
    t1获取锁A,t2获取锁B
    t1获取锁B,t2获取锁A

    java 复制代码
    public class Demo24 {
        private static Object locker1 = new Object();
        private static Object locker2 = new Object();
    
        public static void main(String[] args) throws InterruptedException {
            Thread t1 = new Thread(() ->{
                synchronized (locker1){
                    System.out.println("t1加锁成功locker1");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
    
                    synchronized (locker2){
                        System.out.println("t1加锁成功locker2");
                    }
                }
    
    
            });
    
            Thread t2 = new Thread(() ->{
                synchronized (locker2){
                    System.out.println("t2加锁成功locker2");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
    
                    synchronized (locker1){
                        System.out.println("t2加锁成功locker1");
                    }
                }
            });
            t1.start();
            t2.start();
        }
    }
  3. N个线程M把锁

    经典哲学家问题:有5个哲学家坐在一起吃饭,但只有5根筷子,哲学家就只做两件事情,一件事情为思考,另一件事情就是吃饭,当其中一个哲学家要吃饭时,就会拿起左右两边的筷子,那么此时如果左右相邻的哲学家也想吃饭时,就需要等待正在吃饭的哲学家吃完饭,放下筷子,才能继续吃,在通常情况下,整个系统可以很好的运转,但是当5个哲学家同时拿起左边的筷子时,就会出现死锁问题

(2)死锁的四个必要条件

  1. 锁是互斥的(锁的基本特性)
  2. 锁是不可被抢占的,线程1拿到锁A后,如果线程1不主动释放A,线程2就不能把锁A抢过来(锁的基本特性)

以上两点对于synchronized这样的锁,互斥和不可抢占都是基本特性,我们无法进行干预

  1. 请求和保持。线程1拿到锁A后,不释放A的前提下去拿锁B(代码结构)
    避免出现锁的嵌套即可解决
  2. 循环等待/环路等待/循环依赖 多个线程获取锁的过程存在循环等待(代码结构)
    给锁加编号,约定加锁顺序

如果在获取多把锁的时候,不要构成循环等待,就可以了~一~

假设代码按照请求和保持的方式,获取到N个锁,如何避免出现循环等待呢??一个简单有效的办法:给锁编号,1,2,3....N

约定所有的线程在加锁的时候,都必须按照一定的顺序来加锁.(比如,必须先针对编号小的锁,加锁,后针对编号大的锁加锁)

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

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() ->{
            synchronized (locker1){
                System.out.println("t1加锁成功locker1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (locker2){
                    System.out.println("t1加锁成功locker2");
                }
            }


        });

        Thread t2 = new Thread(() ->{
            synchronized (locker1){
                System.out.println("t2加锁成功locker1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (locker2){
                    System.out.println("t2加锁成功locker2");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

4.Java标准库中的线程安全类

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

ArrayList;LinkedList;HashMap;TreeMap;HashSet;TreeSet;StringBuilder

但是还有一些是线程安全的.使用了一些锁机制来控制.

Vector (不推荐使用)

HashTable(不推荐使用)

ConcurrentHashMap

StringBuffer

5.volatile关键字

(1)内存可见性问题

观察以下代码

java 复制代码
public class Demo25 {
    private static int n = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1= new Thread(() ->{
           while (n == 0){
               //
           }
        });
        Thread t2= new Thread(() ->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            n = scanner.nextInt();
        });
        t1.start();
        Thread.sleep(2000);
        t2.start();
    }
}

原因

如何进行优化导致出现内存可见性问题的?

此时JVM执行这个代码时发现每次循环过程中(1)操作的开销非常大,而且每次执行(1)操作它的结果都是一样的,并且JVM根本没意识到用户可能未来会修改n,于是JVM就做了一个大胆的操作直接将(1)操作给优化掉了,每次循环不会去读取内存中的数据,而是直接读取寄存器/cache中的数据(缓存的结果)

当JVM做出上述决定后此时意味着,循环的开销大幅度的降低,但是当用户修改n的时候返现内存中的n已经改变了,但是t1线程每次循环不会真的读内存,并没有感知到n的改变,也就是说对于线程t1来说n的改变是"不可见的",这样就引起了内存可见性的问题


内存可见性问题本质上是编译器/JVM对代码进行优化出现的bug,如果代码是单线程,优化后的代码非常准确,但在多线程中可能会出现误判,这就导致了内存可见性的问题

解决方案

解决方案一:

在t1线程中添加sleep等待

和读内存相比,sleep相比之下就更慢了,足以等到你scanner输入之后t2线程修改后t1感知

java 复制代码
public class Demo25 {
    private static int n = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1= new Thread(() ->{
           while (n == 0){
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
            System.out.println("t1线程结束");
        });
        Thread t2= new Thread(() ->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            n = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

解决方案二:

添加volatile关键字,该关键字用来修饰一个变量,用来提示编译器这个变量是"易变"的,优化的前提是变量是频繁读取的,而且结果是固定的,此时编译器就会禁止上述优化,以此来确保灭磁都执行从内存中从新读取数据

引入该变量后,编译器生成该代码时,就会给这个变量的读取操作附近生成一些特殊指令,称为"内存屏障",后续JVM执行到此处时就不会进行优化

java 复制代码
public class Demo25 {
    private static volatile int n = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1= new Thread(() ->{
           while (n == 0){
               //
           }
            System.out.println("t1线程结束");
        });
        Thread t2= new Thread(() ->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            n = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

(2)不解决原子性问题

java 复制代码
public class Demo26 {
    private static volatile int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

6.wait和notify

线程在操作系统上的调度是随机的,多个线程需要控制线程直接某个逻辑的先后顺序,此时就可以让后执行的逻辑使用wait,先执行的线程,完成某些逻辑之后,通过notify唤醒对方的wait

(1)wait()

作用:

使当前执行代码的线程进行等待(将线程放到等待队列中)

释放当前锁

满足一定条件时被唤醒,重新尝试获取这个锁

结束等待的条件:

其他线程调用该对象的notify方法.

wait等待时间超时(wait方法提供⼀个带有timeout参数的版本,来指定等待时间).

其他线程调用该等待线程的interrupted方法,导致wait抛出InterruptedException 异常.

java 复制代码
public class Demo27 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        System.out.println("wait之前");
        object.wait();
        System.out.println("wait之后");
    }
}

非法的监视器状态异常,这里的意思是在调用wait方法时,当前锁的状态是不正确的;很明显此处我们都没加锁又何谈解锁呢?wait方法会针对对象先进行解锁所以要使用synchronized关键字来上锁

加上锁之后由于没有notify解锁,所以会一直等待

wait在执行时会将进行解锁,阻塞等待(目的是为了收到通知)同时执行,这两个操作方法内部已经做好了

(2)notify()

notify方法是唤醒等待的线程.

  • notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁
  • 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait状态的线程。(并没有"先来后到")
  • 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。
java 复制代码
public class Demo27 {
    private static Object locker1 = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(() ->{
            System.out.println("wait之前");
            synchronized (locker1) {
                try {
                    locker1.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("wait之后");
        });
        Thread t2 = new Thread(() ->{
            System.out.println("notify之前");
            synchronized (locker1) {
                locker1.notify();
            }
            System.out.println("notify之后");
        });

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

(3)线程饿死问题

定义︰线程饿死是指一个或多个线程由于某种原因无法获取所需的资源或执行机会,导致它们无法继续正常执行,从而被阻塞在某个状态,不能完成其任务。这种情况通常是由于资源竞争或优先级设置不当导致的。

举例说明,第一个人(t1线程)去取钱并上了锁,但机器里没钱,第一个人可以先出来,可以反反复复进出,这就导致其他人只能干等着,无法获取到锁,此时就会产生线程饿死的情况

解决方案:

让第一个人拿到锁的同时进行判定,判定当前能否执行取钱的操作,能则正常执行,不能则主动释放锁,并且进行"阻塞等待"(调用wait实现),此时线程就就不会在后续参与锁的竞争,一直阻塞到取钱的条件具备,此时再由其它线程通知唤醒(notify实现)唤醒这个线程

7.wait和sleep的对比(面试题)

一个是用于线程之间的通信的,一个是让线程阻塞一段时间,

唯---的相同点就是都可以让线程放弃执行一段时间.

  1. wait需要搭配synchronized使用. sleep 不需要.

  2. wait是Object的方法sleep是Thread的静态方法.

相关推荐
xlsw_2 小时前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
神仙别闹3 小时前
基于java的改良版超级玛丽小游戏
java
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭3 小时前
SpringBoot如何实现缓存预热?
java·spring boot·spring·缓存·程序员
暮湫3 小时前
泛型(2)
java
超爱吃士力架3 小时前
邀请逻辑
java·linux·后端
南宫生3 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
转码的小石4 小时前
12/21java基础
java
李小白664 小时前
Spring MVC(上)
java·spring·mvc
GoodStudyAndDayDayUp4 小时前
IDEA能够从mapper跳转到xml的插件
xml·java·intellij-idea
装不满的克莱因瓶4 小时前
【Redis经典面试题六】Redis的持久化机制是怎样的?
java·数据库·redis·持久化·aof·rdb