• 解决线程安全问题&&单例模式


    目录

    1.解决线程安全问题

    1.1 synchronized 关键字(解决了多线程原子性问题)

            1.1.1 synchronized 使用示例

            1.1.2 Java 标准库中的线程安全类

    1.2 volatile 关键字(volatile 能保证内存可见性 )

    1.3 wait 和 notify 

    2.多线程案例

     2.1 单例模式

            2.1.1 饿汉模式

            2.1.2 懒汉模式 - 单线程版

            2.1.3 懒汉模式 - 多线程版


    1.解决线程安全问题

    1.1 synchronized 关键字(解决了多线程原子性问题)

    synchronized 会起到互斥效果 , 某个线程执行到某个对象的 synchronized 中时 , 其他线程如果也执行到同一个对象 synchronized 就会 阻塞等待。
    • 进入 synchronized 修饰的代码块 , 相当于 加锁
    • 退出 synchronized 修饰的代码块 , 相当于 解锁
    【举例】
    还是我们之前的那个例子:两个线程针对同一个变量各自进行自增 5w 次,看看加锁之后的结果:
    1. class Counter {
    2. //保存计数的变量
    3. public int count;
    4. synchronized public void increase() {
    5. count++;
    6. }
    7. }
    8. public class TestDemo1 {
    9. public static Counter counter = new Counter();
    10. public static void main(String[] args) {
    11. Thread t1 = new Thread(() -> {
    12. for(int i = 0; i < 5_0000; i++) {
    13. counter.increase();
    14. }
    15. });
    16. t1.start();
    17. Thread t2 = new Thread(() -> {
    18. for(int i = 0; i < 5_0000; i++) {
    19. counter.increase();
    20. }
    21. });
    22. t2.start();
    23. //main 等待 t1,t2
    24. try {
    25. t1.join();
    26. t2.join();
    27. } catch (InterruptedException e) {
    28. e.printStackTrace();
    29. }
    30. System.out.println("count: " + counter.count);
    31. }
    32. }

    此处的运行结果为 10w,是如何做到的??

    加上锁之后,就只能先执行一个线程,然后再执行另一个线程,假如先执行 线程1,那么其他线程要等到 线程1 释放锁(UNLOCK) 之后,才有机会执行,为什么说是有机会,而不是一定被执行,这就和操作系统的随机调度的问题了。就例如上图中的上厕所,第一个人冲进去后,把门一关,在外面等待的人就是竞争关系,都在等待下一个进去的时机。如果没有竞争者,才能拿到锁。否则不一定拿到锁。

    1.1.1 synchronized 使用示例

    synchronized 本质上要修改指定对象的 " 对象头 ". 从使用角度来看 , synchronized 也势必要搭配一个具体的对象来使用。
    1) 直接修饰普通方法 : 锁的  Counter  对象
    1. class Counter {
    2. //保存计数的变量
    3. public int count;
    4. synchronized public void increase() {
    5. count++;
    6. }
    7. }
    8. public class TestDemo1 {
    9. public static Counter counter1 = new Counter();
    10. public static Counter counter2 = new Counter();
    11. public static void main(String[] args) {
    12. Thread t1 = new Thread(() -> {
    13. for(int i = 0; i < 5_0000; i++) {
    14. counter1.increase();
    15. }
    16. });
    17. t1.start();
    18. Thread t2 = new Thread(() -> {
    19. for(int i = 0; i < 5_0000; i++) {
    20. counter1.increase();
    21. }
    22. });
    23. t2.start();
    24. Thread t3 = new Thread(() -> {
    25. for(int i = 0; i < 5_0000; i++) {
    26. counter2.increase();
    27. }
    28. });
    29. t3.start();
    30. try {
    31. t3.join();
    32. t1.join();
    33. t2.join();
    34. } catch (InterruptedException e) {
    35. e.printStackTrace();
    36. }
    37. System.out.println("count: " + counter1.count);
    38. System.out.println("count: " + counter2.count);
    39. }
    40. }

    首先,要理解加锁是针对具体的对象进行加锁的!对比上一个代码,上一个代码锁上了 Counter 对象,然后两个线程同时获取同一个对象的锁,所以需要阻塞等待,那么运行结果肯定是 10w 次,而这个地方,我重新 new 了一个对象,并且这里三个线程,线程1线程2 是获取同一个对象的锁,所以 线程2 需要等待 线程1,所以  counter1.count   的结果一定是 10w,而 线程3 获取的是另一个对象的锁,与 线程1线程2 不属于竞争关系,所以不需要阻塞等待,所以 counter2.count  的结果一定是 5w 。结合下图加强理解!!

     结合前面的代码,counter1 就对应 1 号坑位,counter2 就对应 2 号坑位。线程1 和 线程2 获取同一个对象的锁,也就是竞争同一个坑位,那么需要阻塞等待,而 坑位2 是没有人的,所以 线程3 不需要阻塞等待,与前面俩线程不存在竞争关系!!

    2) 修饰静态方法 : 锁的  Counter  类的对象
    1. class Counter {
    2. //保存计数的变量
    3. public static int count;
    4. synchronized public static void increase() {
    5. count++;
    6. }
    7. }
    8. public class TestDemo1 {
    9. public static void main(String[] args) {
    10. Thread t1 = new Thread(() -> {
    11. for(int i = 0; i < 5_0000; i++) {
    12. Counter.increase();
    13. }
    14. });
    15. t1.start();
    16. Thread t2 = new Thread(() -> {
    17. for(int i = 0; i < 5_0000; i++) {
    18. Counter.increase();
    19. }
    20. });
    21. t2.start();
    22. try {
    23. t1.join();
    24. t2.join();
    25. } catch (InterruptedException e) {
    26. e.printStackTrace();
    27. }
    28. System.out.println("count: " + Counter.count);
    29. }
    30. }

    此处锁的是类对象,整个 JVM 里只有一个类对象,所以多个线程获取 对象锁的时候,都是针对同一个对象的锁,如果这个对象锁已经被获取了,那么其他线程都需要阻塞等待,所以这里的运行结果就是 10w 次。

    3) 修饰代码块 : 明确指定锁哪个对象。
    1. class Counter {
    2. //保存计数的变量
    3. public int count;
    4. public void increase() {
    5. synchronized(this) {
    6. count++;
    7. }
    8. }
    9. }
    10. public class TestDemo1 {
    11. public static Counter counter1 = new Counter();
    12. public static Counter counter2 = new Counter();
    13. public static void main(String[] args) {
    14. Thread t1 = new Thread(() -> {
    15. for(int i = 0; i < 5_0000; i++) {
    16. counter1.increase();
    17. }
    18. });
    19. t1.start();
    20. Thread t2 = new Thread(() -> {
    21. for(int i = 0; i < 5_0000; i++) {
    22. counter2.increase();
    23. }
    24. });
    25. t2.start();
    26. try {
    27. t1.join();
    28. t2.join();
    29. } catch (InterruptedException e) {
    30. e.printStackTrace();
    31. }
    32. System.out.println("count: " + counter1.count);
    33. System.out.println("count: " + counter1.count);
    34. }
    35. }

    此处的 this 指的就是当前对象,counter1 调用 increase(),锁得就是 counter1 对象,counter2 调用 increase() 方法,锁得就是 counter2 对象!!所以结果是各自 5w。

    🍃以上写法 1,3 可视为等价的,当然写法2,也有另一种写法:

    1. class Counter {
    2.    public void method() {
    3.        synchronized (Counter.class) {
    4.       }
    5.   }
    6. }

    🍁【总结】

    Java 里,任何一个对象,都可以用来作为锁对象。synchronized 的三种用法,无论是使用哪种用法,都要明确锁对象,只有当两个线程针对同一个对象加锁的时候,才会发生竞争;如果是两个线程针对不同对象加锁,则没有竞争!!

    每个对象,内存空间中有一个特殊的区域--对象头(JVM 自带的,里面包含对象的一些特殊信息)

    1.1.2 Java 标准库中的线程安全类

    🍁【不安全的】

    ArrayList / LinkedList / HashMap / TreeMap / HashSet / TreeSet / StringBuilder

    🍁【安全的】 

    Vector ( 不推荐使用 ) / HashTable (不推荐使用 ) / ConcurrentHashMap / StringBuffer

    前两个线程安全的类,不推荐使用,是因为,它里面的所有重要的方法都无脑的加锁了,加锁也是有代价的,因为涉及到了一些线程的阻塞等待和线程调度,可以视为一旦使用了锁,我们的代码基本上就和"高性能"说再见了!!

    另外,还有的虽然没有加锁, 但是不涉及 "修改", 仍然是线程安全的 String


    1.2 volatile 关键字(volatile 能保证内存可见性 )

    🍁【代码示例】

    我想让 线程2 输入一个非0的数,使 线程1 结束:

    1. public class TestDemo2 {
    2. static class Counter {
    3. // volatile public int flag = 0; //加上 volatile 就不会出问题
    4. public int flag = 0;
    5. }
    6. public static void main(String[] args) {
    7. Counter counter = new Counter();
    8. Thread t1 = new Thread(() -> {
    9. while(counter.flag == 0) {
    10. //执行循环,啥也不做!
    11. }
    12. System.out.println("t1 结束");
    13. });
    14. t1.start();
    15. Thread t2 = new Thread(() -> {
    16. //让用户输入一个数字,赋值给 flag
    17. Scanner scanner = new Scanner(System.in);
    18. System.out.println("请输入一个整数: ");
    19. counter.flag = scanner.nextInt();
    20. });
    21. t2.start();
    22. }
    23. }

    这段代码如果不做任何处理,最终我们输入一个非0的数,循环也不会退出,线程1 始终不会结束,原因就是内存可见性问题(上篇博客有详细讲解),线程1 的循环就相当于一直在执行 LOADTEST 指令,由于编译器/JVM的优化,导致了 LOAD 的重复操作都被省略,只执行一次,导致线程2 的修改最新数据没有被 线程1 及时获取,所以出现这样的问题。解决这个问题,就需要使用 volatile 关键字!!

    🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂🍂

    volatile 操作相当于显示的禁止了编译器进行上述优化,是给这个对应的变量加上了"内存屏障"(特殊的二进制指令),JVM 在读取这个变量的时候,因为内存屏障的存在,就知道每次都要重新读取内存的内容,而不是进行草率的优化。

    ---频繁读内存,速度是慢了,但是数据算对了!!

    上述代码,不使用 volitile ,也可以解决,我们在循环里让 线程1 sleep 一下:

    1. public class TestDemo2 {
    2. static class Counter {
    3. public int flag = 0;
    4. }
    5. public static void main(String[] args) {
    6. Counter counter = new Counter();
    7. Thread t1 = new Thread(() -> {
    8. while(counter.flag == 0) {
    9. try {
    10. Thread.sleep(100);
    11. } catch (InterruptedException e) {
    12. e.printStackTrace();
    13. }
    14. }
    15. System.out.println("t1 结束");
    16. });
    17. t1.start();
    18. Thread t2 = new Thread(() -> {
    19. //让用户输入一个数字,赋值给 flag
    20. Scanner scanner = new Scanner(System.in);
    21. System.out.println("请输入一个整数: ");
    22. counter.flag = scanner.nextInt();
    23. });
    24. t2.start();
    25. }
    26. }

    解释:编译器的优化,是根据代码的实际情况来进行的。上个版本里循环体是空,所以循环转速极快!!导致了读内存操作非常频繁,所以就触发了优化!!(读内存操作比操作寄存器的速度慢上几千上万倍)

    这个版本里加了 sleep ,让循环转速一下就慢了!!读内存操作就不怎么频繁了,就不会触发优化了!!

    上述两种方法都可以在一定的情况下解决内存可见性问题,对于方法二(sleep),由于咱们也不好确定什么时候会触发优化,必要的时候最好也加上 volatile!!

    🍁【解决线程安全总结】

    🍃1.synchronized:解决了原子性问题。至于它能不能解决内存可见性问题,网上各有各的说法,不确定!!

    🍃2.volatile:禁止了指令重排序,也解决了内存可见性问题,不保证原子性!!

    1.3 wait 和 notify 

    wait() 方法和 notify() 搭配使用也是解决多线程安全问题的方法之一,这两个方法是 Object 类里面的方法。

    wait() 方法有三个步骤:

    🍃1.释放锁;

    🍃2.等待通知;

    🍃3.当通知到达之后,尝试重新获取锁。

    notify() 方法只有一步:

    🍃1.进行通知。

    【注意】

    因为 wait() 方法第一步是释放锁,所以它的前提是获取锁,所以 wait() 方法需要在synchronized 里面使用,并且调用 wait() 方法的对象和 synchronized 里使用的锁对象是一个对象,还得和调用 notify() 方法的对象是一个对象。

    1.3.1 wait() 方法 

    如果 wait() 方法不放在 synchronized 里面使用,就会抛一个非法监视状态异常,以后在写代码的时候遇到了就知道错在哪了。

    🍁【wait() 错误示例】

    1. public class TestDemo1 {
    2. public static void main(String[] args) throws InterruptedException {
    3. Object object = new Object();
    4. System.out.println("wait 之前");
    5. object.wait();
    6. System.out.println("wait 之后");
    7. }
    8. }

     🍁【wait() 正确用法】

    1. public class TestDemo1 {
    2. public static void main(String[] args) throws InterruptedException {
    3. Object object = new Object();
    4. synchronized (object) {
    5. System.out.println("wait 之前");
    6. object.wait();
    7. System.out.println("wait 之后");
    8. }
    9. }
    10. }

    当然,wait 方法还有一个带参数的版本--wait(long timeout),不加参数就是死等,加了参数之后,就是等待最大时间后自动唤醒!!

    1.3.2 notify() 和 wait() 搭配使用示例

    1. public class TestDemo1 {
    2. public static Object locker = new Object();
    3. public static void main(String[] args) throws InterruptedException {
    4. //处于等待的线程
    5. Thread waitTask = new Thread(() -> {
    6. synchronized (locker) {
    7. try {
    8. System.out.println("wait 开始");
    9. locker.wait();
    10. System.out.println("wait 结束");
    11. } catch (InterruptedException e) {
    12. e.printStackTrace();
    13. }
    14. }
    15. });
    16. waitTask.start();
    17. //负责通知的线程
    18. Thread notifyTask = new Thread(() -> {
    19. Scanner scanner = new Scanner(System.in);
    20. System.out.println("输入任何内容,开始通知: ");
    21. scanner.next();//next() : 阻塞作用,用 sleep 也可以
    22. synchronized (locker) {
    23. System.out.println("notify 开始");
    24. locker.notify();
    25. System.out.println("notify 结束");
    26. }
    27. });
    28. notifyTask.start();
    29. }
    30. }public class TestDemo1 {
    31. public static Object locker = new Object();
    32. public static void main(String[] args) throws InterruptedException {
    33. //处于等待的线程
    34. Thread waitTask = new Thread(() -> {
    35. synchronized (locker) {
    36. try {
    37. System.out.println("wait 开始");
    38. locker.wait();
    39. System.out.println("wait 结束");
    40. } catch (InterruptedException e) {
    41. e.printStackTrace();
    42. }
    43. }
    44. });
    45. waitTask.start();
    46. //负责通知的线程
    47. Thread notifyTask = new Thread(() -> {
    48. Scanner scanner = new Scanner(System.in);
    49. System.out.println("输入任何内容,开始通知: ");
    50. scanner.next();//next() : 阻塞作用,用 sleep 也可以
    51. synchronized (locker) {
    52. System.out.println("notify 开始");
    53. locker.notify();
    54. System.out.println("notify 结束");
    55. }
    56. });
    57. notifyTask.start();
    58. }
    59. }

    🍁【运行结果】

    程序运行时,显示 wait 开始,开始通知:然后等待输入。从运行结果来看,我输入11之后,是先把 notify 开始,notify 结束 打印完之后,才打印 wait 结束,这是为什么??

    🍁【分析】

    线程1 执行到 wait() 方法就阻塞等待了,前面说了 wait() 方法第一步是释放锁,释放锁之后,所以线程2 才能拿到锁,拿到锁之后,通知 线程1 ,但是此时 线程2 还在占用锁,所以 线程2 继续往下执行,等到打印 notify 结束后,释放锁,才轮到 线程1 获取锁,继续向下执行!!

    说了那么多,那么 wait() notify() 怎么就可以解决线程安全呢??(不着急)

    🍁【举个例子】

    例如有两个线程:(先对 wait notify 解决线程安全有个基本认识)

    线程1 需要先计算一个结果,线程2 来使用这个结果,这个时候就 线程2 就可以 wait(),等到线程1 计算完结果后,notify() ,唤醒 线程2,就解决了并发执行等问题带来的线程安全!!

    1.3.3 wait() 和 notify() 机制能有效避免"线程饿死"

    什么叫线程饿死??

    🍁【举个例子】

    上述情况就可以通过 wait notify 来解决,由于前三个滑稽老哥的任务执行不了,他们拿到锁之后就可以先判断一下,当前任务是否可以执行,如果能就执行,如果不能就 waitwait 等到合适的时机(工作人员来了)再继续执行/再继续参与竞争锁!!这样滑稽老哥 D 也就有机会进去取钱了,就不会出现线程饿死了!!

    1.3.4 notifyAll() 

    notify 是唤醒一个线程,而 notifyAll 则是唤醒多个线程,然后这多个线程再一起竞争锁!!

    🍁【区别】

     1.3.5 wait 和 sleep 的对比【面试问题】

    【相同点】

    🍃1.都会让线程进入阻塞。

    【不同点】

    🍃1.阻塞的原因和目的不同;(sleep 目的是为了"放权",暂时让出当前 CPU 的使用权,wait 既可以死等,也可以指定等待时间,涵盖了 sleep 的功能,相较于 sleep 来说,使用的更广泛!!)

    🍃2.进入的状态也不同;(wait 进入 WAITING 状态,sleep 进入 TIMED_WAITING 状态)

    🍃3.被唤醒的条件也不同。


    2.多线程案例

     2.1 单例模式

    单例模式是一种常见的设计模式!!有些对象,在一个程序中只有唯一一个实例,就可以使用单例模式。在单例模式下,对象的实例化被限制了,只能创建一个,多了也创建不了!!单例的具体实现方式,分为"饿汉"和"懒汉"两种!!

    2.1.1 饿汉模式

    饿汉模式:程序启动,则立即创建实例!!

    🍁【代码示例】

    1. class Singleton {
    2. //程序启动,则立即创建实例
    3. private static Singleton instance = new Singleton();
    4. public static Singleton getInstance() {
    5. return instance;
    6. }
    7. //构造方法设为私有,其他类想来 new 就不行了!!
    8. private Singleton() { };
    9. }
    10. public class TestDemo2 {
    11. public static void main(String[] args) {
    12. Singleton singleton1 = Singleton.getInstance();
    13. Singleton singleton2 = Singleton.getInstance();
    14. System.out.println(singleton1 == singleton2);//true
    15. // Error : Singleton singleton = new Singleton();
    16. }
    17. }

    🍃1.饿汉模式中,其他方法不能通过构造方法来 new 实例了,统一基于 getInstance 方法来获取!!

    🍃2.使用静态成员表示实例唯一性) + 让构造方法设为私有(堵住了 new 创建新实例的口子)!!

    2.1.2 懒汉模式 - 单线程版

    1. class SingletonLazy {
    2. private static SingletonLazy instance = null;
    3. //需要用的时候才实例
    4. public static SingletonLazy getInstance() {
    5. if(instance == null) {
    6. instance = new SingletonLazy();
    7. }
    8. return instance;
    9. }
    10. //构造方法设为私有
    11. private SingletonLazy() { };
    12. }

    🍃1.懒汉模式单线程版中,没有立即创建实例,首次调用 getInstance 才会创建实例!!

    🍃2.和饿汉模式一样也是保证实例的唯一性!!

    🍁【思考与问题1】

    上述两个单例模式的代码,在多线程环境下,调用 getInstance ,是线程安全的吗??

    🍃1.对于饿汉模式来说,多线程调用 getInstance ,只涉及了多线程读,所以不会引发线程安全问题!!

    🍃2.对于懒汉模式来说,多线程调用 getInstance,有的地方在读,有的地方在写,所以容易引发线程安全问题!!但是一旦实例创建好了之后,后续的 if 条件就不去了,就不会引发线程安全问题了。

    如何解决懒汉模式在多线程中的线程安全问题??

    2.1.3 懒汉模式 - 多线程版

    对于懒汉模式在多线程中的安全问题,我们的解决办法就是加锁!!

    🍁【代码示例】

    1. class SingletonLazy {
    2. private static SingletonLazy instance = null;
    3. //需要用的时候才实例
    4. public static SingletonLazy getInstance() {
    5. synchronized (instance) {
    6. //把读和写打包成原子操作
    7. if(instance == null) {//读操作
    8. instance = new SingletonLazy();//写操作
    9. }
    10. }
    11. return instance;
    12. }
    13. //构造方法设为私有
    14. private SingletonLazy() { };
    15. }

    🍃1.我们需要对线程加锁(synchronized

    🍃2.由于线程安全问题是一个线程读和另一个线程写引发的,那么我们就将这两个操作打包成一个原子操作,所以锁就需要加在 if 条件的外边。

    🍁【思考与问题2】 

    加上锁之后,线程安全问题得到解决了。那么问题又来了,在创建好实例之后,后续在调用 getInstance 的时候就不应该再尝试加锁了,因为再尝试加锁,那么你的程序就要和"高性能"说拜拜了,加锁操作是非常影响效率的!!

    🍁【解决方案】

    使用双重 if 判定 , 降低锁竞争的频率!!
    1. class SingletonLazy {
    2. private static SingletonLazy instance = null;
    3. //需要用的时候才实例
    4. public static SingletonLazy getInstance() {
    5. //使用双重 if 判定, 降低锁竞争的频率!!
    6. if(instance == null) {
    7. synchronized (instance) {
    8. //把读和写打包成原子操作
    9. if(instance == null) {//读操作
    10. instance = new SingletonLazy();//写操作
    11. }
    12. }
    13. }
    14. return instance;
    15. }
    16. //构造方法设为私有
    17. private SingletonLazy() { };
    18. }

    外层的 if 条件看似多余,实则不然。当我们第一次实例创建好了之后,其他 getInstance 的调用者在进行外层 if 条件判断的时候,就进不来了,就没有机会尝试获取锁,就降低了这其中导致程序低效的可能,因为我们在获取锁的过程中往往会涉及到阻塞,阻塞的时间可能会很长!!

    🍁【结合下图理解】

    🍁【思考与问题3】

    以上代码已经解决了多个线程获取锁低效的问题,那么问题又来了。多个线程频繁的读和写,这势必会让我们联想到内存可见性问题(上一篇博客有详细讲到)!!如何解决??

    🍁解决方案:用 volatile 修饰 instance

    但是,每个线程有自己的上下文,每个线程有自己的寄存器内容,按理来说,编译器/JVM/操作系统的不应该对读操作进行优化的。但是话又说回来,编译器/JVM/操作系统的优化是站在什么样的角度,咱也不知道!!所以这里为了保险起见,还是加上 volatile 更稳健!!

    private static volatile SingletonLazy instance = null;

    对于加上 volatile 的原因是什么这个问题,存在很多争议,有些兄弟认为是内存可见性的问题,有些兄弟认为是指令重排序(上一篇博客详细讲到)的问题!!至于是哪一种,我也不确定,因为多线程的随机调度等原因,带来了很多的可能性,不好验证,所以这里我持保守意见!!


    本篇博客就到这里了,谢谢观看!!

  • 相关阅读:
    WebExceptionHandler详解
    Python进行时间序列平稳性检验(ADF Test)
    qt 实现PDF阅读器
    【老王读Spring Transaction-1】从EnableTransactionManagement顺藤摸瓜,研究@Transactional的实现原理
    Java与Scala编译的简单对比
    zabbix配置触发器
    springMvc2-spring jar包下载
    极市直播丨南京理工大学魏秀参、沈阳:大规模细粒度图像检索
    代码随想录二刷day46
    OpenCV-交互相关接口
  • 原文地址:https://blog.csdn.net/xaiobit_hl/article/details/125991975