多线程(续—解决线程不安全问题)(超详细)

发布于:2025-03-25 ⋅ 阅读:(29) ⋅ 点赞:(0)

目录

一、synchronized 关键字 - 监视器锁 monitor lock

1. synchronized的特性

1) 互斥性

2) 可重入

死锁

2. synchronized 使用案例

1) 修饰代码块

(1).锁任意对象

 (2).锁当前对象

2) 直接修饰普通方法

 3) 修饰静态方法

3. 了解Java标准库中的线程安全类

二、volatile 关键字

1. volatile能保证内存可见性

2. volatile不保证原子性

三、wait和notify

1. wait()方法

 2. notify()方法

3. notifyAll方法

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

 四、总结

1. 保证线程安全的思路


 欢迎观看我滴上一篇关于 多线程的博客呀,直达地址:

多线程(超详细) (ε≡٩(๑>₃<)۶ 一心向学)  感谢各位大佬的支持 💓💓💓


 在上一篇的博客中,介绍了什么是多线程,使用多线程有存在什么安全问题?现在呢,需要解决这个关于线程安全的问题。

先来回忆一下,线程安全产生的原因呢,有5个原因,但是对于:

1、根本原因的抢占式执行,我们是解决不了的,因为这是操作系统的底层设计,我们左右不了。

2、对于第二个原因,同时修改同一个变量的话,是和写的代码相关,虽然可以解决一下问题,但是这个方案并不够通用。

3、原子性,是解决线程安全问题最主要的方案。那么呢,如何去解决呢?通过——加锁/解锁操作,就是将 不是原子的操作,打包成一个原子操作。计算机中的锁具有 互斥/排他 的特性。

4、内存可见性,这个如何解决后面会出现解决办法是使用 volatile 关键字

5、指令重排序

接下来,进行介绍一下如何解决线程不安全的问题


一、synchronized 关键字 - 监视器锁 monitor lock

通过 synchronized 进行加锁 可以解决 原子性的问题

监视器锁 monitor lock 是 JVM中的采用的一个术语,使用锁的过程中,抛出一下异常可能会看到,这样的报错信息。

1. synchronized的特性

1) 互斥性

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

进入 synchronized 修饰的代码块,相当于加锁

退出 synchronized 修饰的代码块,相当于解锁

 synchronized 中的 锁对象 中用的锁是Java中的任意一个对象。

如果是两个线程的话,就需要修饰同一个对象加锁才能产生互斥效果。这样才可以使其一个线程加锁,另一个线程进行等待解锁,这个过程也称之为——“阻塞等待”。

2) 可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。

要需要先了解一下死锁这个问题:

死锁

什么是自己把自己锁死?

就是一个线程没有释放锁,但是然后又开始尝试再次加锁。 

比如:

第一次加锁成功。

第二次加锁,锁已经被占用,阻塞等待。

按照之前的设定来说,当第一次加锁成功之后,第二次加锁需要进行等待,直到第一次的锁释放才会进行第二次加锁,但是第一个锁也是由该线程执行的,这样就无法进行解锁,这时候就会出现死锁。这样的锁称之为 “不可重入锁

死锁还有一种情况:

两个线程,两把锁每个线程获取到一把锁之后,尝试获取对方的锁。

比如下述代码:(一定是拿到第一把锁之后,不释放的前提下,去那第二把锁)

public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker2) {
                    System.out.println("t1 线程两个锁都获取到");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker2) {

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

                synchronized (locker1) {
                    System.out.println("t2 线程两个锁都获取到");
                }
            }

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

构成死锁的必要条件(重要):

1、锁是互斥的(锁的基本性质)。一个线程拿到锁之后,另一个线程再尝试获取锁,必须要阻塞等待

2、锁是不可抢夺的(锁的基本特性)。线程1拿到锁,线程2 也尝试获取这个锁,线程2必须阻塞等待而不是线程2直接把锁抢夺过来。

3、请求和保持。一个线程拿到锁1之后,不释放锁1的前提下,请求获取锁2

4、循环等待。多个线程,多把锁之间的等待过程,过程了“循环”。比如: A等待B,B也等待A

或者 A等待B,B等待C,C等待A

死锁第三种情况,N个线程M把锁

这种情况的经典 “死锁” —— 哲学家问题 

注意:面试中,关于死锁也是经常提问的一个问题。也可能是让你手写一个死锁,也就是上述的底代码 


 但是呢 Java 中的 synchronized 是可重入锁,不会出现上述的问题。

for (int i = 0; i < 50000; i++) {
    synchronized (locker) {
        synchronized (locker) {
            count++;
        }
    }
}

在可重入锁的内部,包含了 "线程持有者" 和 "计数器" 两个信息。

• 如果某个线程加锁的时候,发现锁已经被人占用,并且占用的就是自己,那么仍然可以继续获取到锁,并让计数器自增。

• 解锁的时候计数器递减为0的时候呢,才是真正释放锁。别的线程才可以占用


2. synchronized 使用案例

synchronized 本质上要修改指定对象的 "对象头"。从使用角度来看,synchronized 也势必要搭配一个具体的对象来使用。

1) 修饰代码块

要明确指定锁哪个对象。

(1).锁任意对象
private Object locker = new Object();

public void method() {
    synchronized (locker) { 
    }
}
 (2).锁当前对象
public void method() {
    synchronized (this) {
    }
}

2) 直接修饰普通方法

锁的 Test4 对象

public class Test4 {
    public synchronized void method() {
    }
}

 3) 修饰静态方法

锁的 Test4的类对象

public class Test4 {
    public synchronized static void method() {
    }
}

注意:需要重点了解使用  synchronized 锁的是什么。只有当两个线程同时竞争同一把锁,才会产生阻塞等待。两个线程分别尝试获取不同的锁,不会产生竞争。


3. 了解Java标准库中的线程安全类

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

如:ArrayList、LinkedList、HashMap、TreeMap、HashSet、TreeSet、StringBuilder等

当然也存在线程安全的,使用一些锁来控制

如:Vector(不推荐)、HashTable(不推荐)、ConcurrentHashMap、StringBuffer 等

还有的是虽然没有 加锁 ,但是 不涉及“修改”,所以仍然是线程安全

如:String


二、volatile 关键字

1. volatile能保证内存可见性

volatile 关键字修饰的变量,能够保证“内存可见性”。

再来回想一下上一篇博客中在 内存可见性 的时候展示的图片:

内存可见性 就是直接访问工作内存(实际是CPU的寄存器或者CPU的缓存),这样速度非常快,但是可能会出现数据不一致的情况。

但是加上 volatile后,就需要强制读写内存,这样虽然速度变慢了,但是呢数据更加精准了。

volatile 修饰变量后操作流程如下:

代码在写入 volatile修饰的变量的时候:

 • 改变线程工作内存中volatile变量副本的值

 • 将改变后的副本的值从工作内存刷新到主内存中

代码在读取 volatile修饰的变量的时候:

 • 从主内存中读取 volatile变量的最新值到线程的工作内存中

 • 从工作内存中读取 volatile变量的副本

用代码更加直观的了解一下:

public class Test7 {
    private static int flag = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0) {
                // 一些代码
            }
            System.out.println("t1 线程结束");
        });
        Thread t2 = new Thread(() -> {
            // 针对 flag 进行修改
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入 flag 的值: ");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

在这个代码中的意思是:

1、创建线程 t1和t2

2、t1 中包含一个循环,这个循环是当 flag==0 的时候为循环条件

3、t2 中从键盘中读取一个整数,赋值给 flag

4、预期是当 用户输入非0的时候,就会跳出循环,ti 线程结束

但是如果执行上述的代码的话,就会发现 这个代码不论输入什么都不会跳出循环结束 t1线程。这个就是 “内存可见性问题”

一个线程读取,一个线程修改,但是修改线程所修改的值,并没有被读取线程读取到。

这是因为 编译器优化 的问题:

在上述的代码中 while循环短时间内,会执行很多次,即使你输入的非常快也不行。在while循环的时候,JVM就会感觉到while循环的结果都是一样的,那么就会进行优化,把其读取内存的操作 优化成 读取寄存器的操作,那么后续就直接在寄存器中读取。那么等输入flag的值之后,t1就感觉不到了,不会去读取内存,所以就会出现 —— 内存可见性问题

解决也是很简单的,直接上代码:

public class Test7 {
    private volatile static int flag = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0) {
                // 一些代码
            }
            System.out.println("t1 线程结束");
        });
        Thread t2 = new Thread(() -> {
            // 针对 flag 进行修改
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入 flag 的值: ");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

只是把 flag 变量的修饰多加一个  volatile之后,就会强制在内存中读取了。


2. volatile不保证原子性

volatile 和 synchronized 有着本质的区别。synchronized 能够保证原子性,volatile 保证的是内存可见性。

三、wait和notify

由于线程之间是抢占式执行的,因此线程之间执行的先后顺序难以预知。但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序。

完成这个 协调 工作,主要涉及到 三个方法:

 • wait() / wait(long timeout) : 让当前线程进入等待状态

 • notify() / notifyAll() : 唤醒在当前对象上等待的状态


1. wait()方法

wait 做的事情:

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

 • 释放当前的锁

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

注:wait 需要搭配 synchronized 来使用,脱离 synchronized 使用 wait 会直接抛出异常

 wait 结束等待的条件:

 • 其他线程调度该对象的 notfiy 方法。

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

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

使用代码观察一下如何使用:

public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        System.out.println("wait 之前");
        synchronized (object) {

            object.wait();

        }
        System.out.println("wait 之后");
    }

要求:synchronized 的锁对象和 引用 wait 的对象是需要一样的。 

这里执行到 object.wait() 的时候就会一直等待下去,因为没有 notify 进行唤醒 wait 方法。

在上面看到 wait() 里面是可以带参数的,那么是什么意思呢?

非常简单,就是没有参数会一直等待直到进行唤醒,但是有参数就会等待参数中的时间,当时间过了但没有进行唤醒的话,就会放弃等待,直接往后执行。

public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        System.out.println("wait 之前");
        synchronized (object) {

            object.wait(1000);

        }
        System.out.println("wait 之后");
    }

 2. notify()方法

notfiy 方法是唤醒等待的线程。

 • 方法 notify() 也要在同步方法或同步块中调用,该方法使用来通知哪些可能等待该对象的对象锁的其它线程,对其发出通知 notify,并是它们重新获取该对象的对象锁。

 • 如果有多个线程等待,则有线程调器随机挑选出一个呈 wait 状态的线程。(并没有“先来后到”)

 • 在 notify() 方法后,当前线程不会马上释放该对象锁,要等到执行 notify() 方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。

这样文字表示有点难以理解,直接看代码:

public static void main(String[] args) {
        Object locker = new Object();

        Thread t1 = new Thread(() -> {
            try {
                System.out.println("wait 之前");
                synchronized (locker) {
                    locker.wait();
                }
                System.out.println("wait 之后");
            } catch (InterruptedException e) {
                throw new RuntimeException();
            }
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入任意内容, 通知唤醒 t1");
            scanner.next();

            synchronized (locker) {
                locker.notify();
                System.out.println("notify执行完毕");
            }
        });

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

 在使用 wait 和 notify 的时候一定要保持  是先 wait 后 notify 的顺序这样notify才能把wait唤醒。并且要注意 一个 notify 只能唤醒 一个 wait。

3. notifyAll方法

notify 是唤醒一个线程而 notifyAll可以一次性唤醒所有等待线程。

public static void main(String[] args) {
        Object locker = new Object();

        Thread t1 = new Thread(() -> {
            try {
                System.out.println("t1 wait 之前");
                synchronized (locker) {
                    locker.wait();
                }
                System.out.println("t1 wait 之后");
            } catch (InterruptedException e) {
                throw new RuntimeException();
            }
        });
        Thread t2 = new Thread(() -> {
            try {
                System.out.println("t2 wait 之前");
                synchronized (locker) {
                    locker.wait();
                }
                System.out.println("t2 wait 之后");
            } catch (InterruptedException e) {
                throw new RuntimeException();
            }
        });

        Thread t3 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入任意内容, 通知唤醒 所有等待线程");
            scanner.next();

            synchronized (locker) {
                locker.notifyAll();
                System.out.println("notifyAll执行完毕");
            }
        });

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

执行上述的代码,那么就可以看到 notifyAll 唤醒了 t1 和 t2 两个线程。

但是有出现一个新的问题:

当 t1线程 和 t2线程 同时被唤醒之后,因为是同一个对象,那么 t1 和 t2 需要竞争锁。所以不是同时执行,会出现先后顺序的执行。


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

其实理论上是没有可比性的,因为一个是用于线程之间的通信,一个是让线程阻塞一段时间。

wait 和 sleep 最主要的区别,在与针对锁的操作:

1)wait 必须要搭配锁。先加锁,才能用 wait,sleep 不需要。

2)如果都是在 synchronized 内部使用,wait会释放锁,sleep不会释放锁。

3)wait 是Object 的方法,而 sleep 是 Thread 的静态方法。

 四、总结

1. 保证线程安全的思路

1)使用没有共享资源的模型

2)使用共享资源只读,不写的模型

(1)不需要写共享资源的模型

(2)使用不可变对象

3)直面线程安全(重点)

(1)保证原子性

(2)保证顺序性

(3)保证可见性

如果觉得文章不错的话,期待你的一键三连哦,你的鼓励就是我的动力,让我们一起加油,顶峰相见。拜拜喽~~我们下次再见💓💓💓💓