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

目录

1.解决线程安全问题

1.1 方式一 : 使用 synchronized / Lock

1.2 方式二 : 使用 Java 标准库中的线程安全类

1.3 方式三 : 使用 wait 和 notify 

2. volatile 关键字

3.多线程案例

3.1 单例模式


1.解决线程安全问题

1.1 方式一 : 使用 synchronized / Lock

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

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

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

1.1.1 synchronized 使用示例

synchronized 本质上要修改指定对象的 " 对象头 ". 从使用角度来看 , synchronized 也势必要搭配一个具体的对象来使用。
1) 直接修饰普通方法 : 锁的  Counter  对象
class Counter {
    //保存计数的变量
    public int count;
    synchronized public void increase() {
        count++;
    }
}
public class TestDemo1 {
    public static Counter counter1 = new Counter();
    public static Counter counter2 = new Counter();
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < 5_0000; i++) {
                counter1.increase();
            }
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 5_0000; i++) {
                counter1.increase();
            }
        });
        t2.start();
        Thread t3 = new Thread(() -> {
            for(int i = 0; i < 5_0000; i++) {
                counter2.increase();
            }
        });
        t3.start();
        try {
            t3.join();
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count: " + counter1.count);
        System.out.println("count: " + counter2.count);
    }
}

首先,要理解加锁是针对具体的对象进行加锁的!对比上一个代码,上一个代码锁上了 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  类的对象
class Counter {
    //保存计数的变量
    public static int count;
    synchronized public static void increase() {
        count++;
    }
}
public class TestDemo1 {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < 5_0000; i++) {
                Counter.increase();
            }
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 5_0000; i++) {
                Counter.increase();
            }
        });
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count: " + Counter.count);
    }
}

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

3) 修饰代码块 : 明确指定锁哪个对象。
class Counter {
    //保存计数的变量
    public int count;
    public void increase() {
        synchronized(this) {
            count++;
        }
    }
}
public class TestDemo1 {
    public static Counter counter1 = new Counter();
    public static Counter counter2 = new Counter();
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < 5_0000; i++) {
                counter1.increase();
            }
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 5_0000; i++) {
                counter2.increase();
            }
        });
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count: " + counter1.count);
        System.out.println("count: " + counter1.count);
    }
}

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

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

class Counter {
    public void method() {
        synchronized (Counter.class) {
       }
   }
}

🍁【总结】

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

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

1.2 方式二 : 使用 Java 标准库中的线程安全类

  • Vector (不推荐使用)
  • HashTable (不推荐使用)
  • ConcurrentHashMap
  • StringBuffer
  • String

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

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() 错误示例】

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

 🍁【wait() 正确用法】

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

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

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

public class TestDemo1 {
    public static Object locker = new Object();
    public static void main(String[] args) throws InterruptedException {
        //处于等待的线程
        Thread waitTask = new Thread(() -> {
            synchronized (locker) {
                try {
                    System.out.println("wait 开始");
                    locker.wait();
                    System.out.println("wait 结束");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        waitTask.start();
        //负责通知的线程
        Thread notifyTask = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入任何内容,开始通知: ");
            scanner.next();//next() : 阻塞作用,用 sleep 也可以
            synchronized (locker) {
                System.out.println("notify 开始");
                locker.notify();
                System.out.println("notify 结束");
            }
        });
        notifyTask.start();
    }
}public class TestDemo1 {
    public static Object locker = new Object();
    public static void main(String[] args) throws InterruptedException {
        //处于等待的线程
        Thread waitTask = new Thread(() -> {
            synchronized (locker) {
                try {
                    System.out.println("wait 开始");
                    locker.wait();
                    System.out.println("wait 结束");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        waitTask.start();
        //负责通知的线程
        Thread notifyTask = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入任何内容,开始通知: ");
            scanner.next();//next() : 阻塞作用,用 sleep 也可以
            synchronized (locker) {
                System.out.println("notify 开始");
                locker.notify();
                System.out.println("notify 结束");
            }
        });
        notifyTask.start();
    }
}

🍁【运行结果】

程序运行时,显示 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. volatile 关键字

🍁【代码示例】

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

public class TestDemo2 {
    static class Counter {
        // volatile public int flag = 0; //加上 volatile 就不会出问题
        public int flag = 0;
    }
    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            while(counter.flag == 0) {
                //执行循环,啥也不做!
            }
            System.out.println("t1 结束");
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            //让用户输入一个数字,赋值给 flag
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            counter.flag = scanner.nextInt();
        });
        t2.start();
    }
}

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

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

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

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

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

public class TestDemo2 {
    static class Counter {
        public int flag = 0;
    }
    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            while(counter.flag == 0) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1 结束");
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            //让用户输入一个数字,赋值给 flag
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            counter.flag = scanner.nextInt();
        });
        t2.start();
    }
}

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

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

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

🍁【解决线程安全总结】

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

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

3.多线程案例

3.1 单例模式

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

3.1.1 饿汉模式

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

🍁【代码示例】

class Singleton {
    //程序启动,则立即创建实例
    private static Singleton instance = new Singleton();

    public static Singleton getInstance() {
        return instance;
    }
    //构造方法设为私有,其他类想来 new 就不行了!!
    private Singleton() { };
}
public class TestDemo2 {
    public static void main(String[] args) {
        Singleton singleton1 = Singleton.getInstance();
        Singleton singleton2 = Singleton.getInstance();
        System.out.println(singleton1 == singleton2);//true
        //  Error :   Singleton singleton = new Singleton();
    }
}

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

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

3.1.2 懒汉模式 - 单线程版

class SingletonLazy {
    private static SingletonLazy instance = null;
    //需要用的时候才实例
    public static SingletonLazy getInstance() {
        if(instance == null) {
            instance = new SingletonLazy();
        }
        return instance;
    }
    //构造方法设为私有
    private SingletonLazy() { };
}

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

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

🍁【思考与问题1】

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

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

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

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

3.1.3 懒汉模式        

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

🍁【代码示例】

class SingletonLazy {
    private static SingletonLazy instance = null;
    //需要用的时候才实例
    public static SingletonLazy getInstance() {
        synchronized (instance) {
            //把读和写打包成原子操作
            if(instance == null) {//读操作
                instance = new SingletonLazy();//写操作
            }
        }
        return instance;
    }
    //构造方法设为私有
    private SingletonLazy() { };
}

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

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

🍁【思考与问题2】 

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

🍁【解决方案】

使用双重 if 判定 , 降低锁竞争的频率!!
class SingletonLazy {
    private static SingletonLazy instance = null;
    //需要用的时候才实例
    public static SingletonLazy getInstance() {
        //使用双重 if 判定, 降低锁竞争的频率!!
        if(instance == null) {
            synchronized (instance) {
                //把读和写打包成原子操作
                if(instance == null) {//读操作
                    instance = new SingletonLazy();//写操作
                }
            }
        }
        return instance;
    }
    //构造方法设为私有
    private SingletonLazy() { };
}

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

🍁【结合下图理解】

🍁【思考与问题3】

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

🍁解决方案:用 volatile 修饰 instance

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

private static volatile SingletonLazy instance = null;

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


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

  • 9
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 12
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Master_hl

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值