7.并发编程之并发安全问题

1 线程安全性

什么是线程安全性?可以这么理解, 我们所写的代码在并发情况下使用 时,总是能表现出正确的行为;反之,未实现线程安全的代码,表现的行为是不可预知的,有可能正确,而绝大多数的情况下是错误的。

线程的行为(尤其是在未正确同步的情况下)可能会造成混淆并且违反直觉。

如果要实现线程安全性,就要保证我们的类是线程安全的的。在《Java 并发 编程实战》中,定义"类是线程安全的"如下:

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在调用代码中不需要任何额外的同步或者协同,这个类都 能表现出正确的行为,那么就称这个类是线程安全的

2 如何实现线程安全?

2.1 线程封闭

实现好的并发是一件困难的事情,所以很多时候我们都想躲避并发。避免并发最简单的方法就是线程封闭。什么是线程封闭呢?

就是把对象封装到一个线程里, 只有这一个线程能看到此对象。那么这个对象就算不是线程安全的也不会出现任何安全问题。

2.1.1 栈封闭

栈封闭是我们编程当中遇到的最多的线程封闭。什么是栈封闭呢?简单的说就是局部变量多个线程访问一个方法, 此方法中的局部变量都会被拷贝一份到线程栈中由于局部变量不被多个线程所共享的,也就不会出现并发问题。所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。

2.1.2 TheadLocal

ThreadLocal 是实现线程封闭的最好方法。ThreadLocal 内部维护了一个 Map, Map 的 key 是每个线程的名称,而 Map 的值就是我们要封闭的对象。每个线程中的对象都对应着 Map 中一个值,也就是 ThreadLocal 利用 Map 实现了对象的线程封闭。

2.1.3 无状态的类

没有任何成员变量的类,就叫无状态的类,这种类一定是线程安全的。

java 复制代码
/**
 * 类说明:无状态的类
 */
public class StatelessClass {
    
    public int add(int a, int b) {
        return a+b;
    }
}

如果这个类的方法参数中使用了对象,也是线程安全的吗?比如:

java 复制代码
/**
 * 类说明:无状态的类
 */
public class StatelessClass {

    public int add(int a, int b) {
        return a+b;
    }

    public void getUserInfo(UserVo userVo) {
        //: TODO
    }
}

当然也是,为何?因为多线程下的使用,固然 userVo 这个对象的实例会不正常,但是对于 StatelessClass 这个类的对象实例来说,它并不持有 UserVo 的对象实例,它自己并不会有问题,有问题的是 UserVo 这个类,而非 StatelessClass 本身。

2.1.4 让类不可变

让状态不可变,加 final 关键字,对于一个类,所有的成员变量应该是私有的, 同样的只要有可能, 所有的成员变量应该加上 final 关键字,但是加上 final ,要注意如果成员变量又是一个对象时, 这个对象所对应的类也要是不可变,才能保证整个类是不可变的。

但是要注意, 一旦类的成员变量中有对象,上述的 final 关键字保证不可变并不能保证类的安全性。因为在多线程下,虽然对象的引用不可变,但是对象在堆上的实例是有可能被多个线程同时修改的,没有正确处理的情况下,对象实例在堆中的数据是不可预知的。

java 复制代码
/**
 * 类说明:不可变的类
 */
public class ImmutableClass {

    private final String name = "hello world";
    private final Object obj; // 加上它就变成线程不安全了

    public ImmutableClass(Object obj) {
        this.obj = obj;
    }
}

2.2 加锁和 CAS

我们最常使用的保证线程安全的手段, 使用 synchronized 关键字,使用显式 锁,使用各种原子变量,修改数据时使用 CAS 机制等等。

2.2.1 死锁

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁

举个例子: A 和 B 去按摩洗脚,都想在洗脚的时候,同时顺便做个头部按摩,13 技师擅长足底按摩,14 擅长头部按摩。

这个时候 A 先抢到 14,B 先抢到 13,两个人都想同时洗脚和头部按摩,于是就互不相让, 扬言我死也不让你,这样的话,A 抢到 14,想要 13,B 抢到 13, 想要 14,在这个想同时洗脚和头部按摩的事情上 A 和 B 就产生了死锁。 怎么解 决这个问题呢?

第一种,假如这个时候,来了个 15,刚好也是擅长头部按摩的, A 又没有两个脑袋, 自然就归了 B,于是 B 就美滋滋的洗脚和做头部按摩,剩下 A 在旁边气 鼓鼓的,这个时候死锁这种情况就被打破了,不存在了。

第二种, C 出场了,用武力强迫 A 和 B,必须先做洗脚,再头部按摩,这种情况下, A 和 B 谁先抢到 13,谁就可以进行下去,另外一个没抢到的,就等着, 这种情况下,也不会产生死锁。

所以总结一下:

  1. 死锁是必然发生在多操作者(M>=2 个)争夺多个资源(N>=2 个,且 N<=M) 才会发生这种情况。很明显,单线程自然不会有死锁,只有 B 一个去,不要 2 个, 打十个都没问题; 单资源呢?只有 13,A 和 B 也只会产生激烈竞争, 打得不 可开交,谁抢到就是谁的,但不会产生死锁。
  2. 争夺资源的顺序不对,如果争夺资源的顺序是一样的, 也不会产生死锁; 3、争夺者对拿到的资源不放手。

学术化的定义

死锁的发生必须具备以下四个必要条件。

  1. 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内 某资源只由一个进程占用。如果此时还有其它进程请求资源, 则请求者只能等待, 直至占有资源的进程用毕释放。
  2. 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源 请求, 而该资源已被其它进程占有, 此时请求进程阻塞, 但又对自己已获得的其 它资源保持不放。
  3. 3)不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只 能在使用完时由自己释放。
  4. 环路等待条件: 指在发生死锁时, 必然存在一个进程------资源的环形链, 即进程集合{P0,P1,P2, ···,Pn}中的 P0 正在等待一个 P1 占用的资源; P1 正在等待 P2 占用的资源, ......,Pn 正在等待已被 P0 占用的资源。

理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避 免、预防和解除死锁。

只要打破四个必要条件之一就能有效预防死锁的发生。

  1. 打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。
  2. 打破不可抢占条件: 当一进程占有一独占性资源后又申请一独占性资源而无 法满足,则退出原占有的资源。
  3. 打破占有且申请条件: 采用资源预先分配策略, 即进程运行前申请全部资源, 满足则运行,不然就等待,这样就不会占有且申请。
  4. 打破循环等待条件: 实现资源有序分配策略, 对所有设备实现分类编号, 所 有进程只能采用按序号递增的形式申请资源。

避免死锁常见的算法有有序资源分配法、银行家算法。

2.2.1.1 死锁的现象、危害和解决

在我们 IT 世界有没有存在死锁的情况,有:数据库里多事务而且要同时操 作多个表的情况下。所以数据库设计的时候就考虑到了检测死锁和从死锁中恢复 的机制。比如 oracle 提供了检测和处理死锁的语句,而 mysql 也提供了"循环 依赖检测的机制"

在 Java 世界里存在着多线程争夺多个资源,不可避免的存在着死锁。那么我们在编写代码的时候什么情况下会发生呢?

现象

2.2.1.2 简单顺序死锁
java 复制代码
public class NormalDeadLock {

    private static Object No13 = new Object();//第一个锁
    private static Object No14 = new Object();//第二个锁

    private static void ZhangFeiDo() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        synchronized (No13){
            System.out.println(threadName + " get No13");
            Thread.sleep(100);
            synchronized (No14){
                System.out.println(threadName + " get No14");
            }
        }
    }

    private static void guanErYeDo() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        synchronized (No13){
            System.out.println(threadName + " get No13");
            Thread.sleep(100);
            synchronized (No14){
                System.out.println(threadName + " get No14");
            }
        }
    }

    private static class ZhangFei extends Thread{
        private String name;
        public ZhangFei(String name) {
            this.name = name;
        }
        @Override
        public void run() {
            Thread.currentThread().setName(name);
            try {
                ZhangFeiDo();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    private static class GuanErYe extends Thread{
        private String name;
        public GuanErYe(String name) {
            this.name = name;
        }
        @Override
        public void run() {
            Thread.currentThread().setName(name);
            try {
                guanErYeDo();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        GuanErYe guanErYe = new GuanErYe("GuanErYe");
        guanErYe.start();
        ZhangFei zhangFei = new ZhangFei("ZhangFei");
        zhangFei.start();
    }
}
2.1.2.3 动态顺序死锁

顾名思义也是和获取锁的顺序有关,但是比较隐蔽,不像简单顺序死锁, 往往从代码一眼就看出获取锁的顺序不对。

java 复制代码
public class DynamicDeadLock {

    private static Object No1 = new Object();//第一个锁
    private static Object No2 = new Object();//第二个锁

    /**
     * 公共业务方法
     */
    private static void businessDo(Object first,Object second) throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        synchronized (first){
            System.out.println(threadName + " get first");
            Thread.sleep(100);
            synchronized (second){
                System.out.println(threadName + " get second");
            }
        }
    }

    private static class ZhangSan extends Thread{

        private String name;

        public ZhangSan(String name) {
            this.name = name;
        }

        @Override
        public void run() {
            Thread.currentThread().setName(name);
            try {
                businessDo(No1,No2);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        //主线程
        Thread.currentThread().setName("lisi");
        ZhangSan zhangSan = new ZhangSan("zhangsan");
        zhangSan.start();
        businessDo(No1,No2);
    }
}

危害

  1. 线程不工作了,但是整个程序还是活着的。
  2. 没有任何的异常信息可以供我们检查。
  3. 一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对生产平台的程序来说,这是个很严重的问题。
2.1.2.4 实际工作中的死锁

死锁出现时间不定,不是每次必现;一旦出现没有任何异常信息,只知道这个应用的所有业务越来越慢,最后停止服务,无法确定是哪个具体业务导致的问题;测试部门也无法复现,并发量不够。

2.1.2.4.1 定位

要解决死锁,当然要先找到死锁,通过 jps 查询应用的 id,再通过 jstack id 查看应用的锁的持有情况。



2.1.2.4.2 修正

关键是保证拿锁的顺序一致。

java 复制代码
/**
 *类说明:不会产生死锁
 */
public class SafeOperate {

    private static Object No1 = new Object();//第一个锁
    private static Object No2 = new Object();//第二个锁
    private static Object tieLock = new Object();//第三把锁

    public void transfer(Object first,Object second)
            throws InterruptedException {

        int firstHash = System.identityHashCode(first);
        int secondHash = System.identityHashCode(second);

        if(firstHash<secondHash){
            synchronized (first){
                System.out.println(Thread.currentThread().getName()+" get "+first);
                Thread.sleep(100);
                synchronized (second){
                    System.out.println(Thread.currentThread().getName()+" get "+second);
                }
            }
        }else if(secondHash<firstHash){
            synchronized (second){
                System.out.println(Thread.currentThread().getName()+" get"+second);
                Thread.sleep(100);
                synchronized (first){
                    System.out.println(Thread.currentThread().getName()+" get"+first);
                }
            }
        }else{
            synchronized (tieLock){
                synchronized (first){
                    synchronized (second){
                        System.out.println(Thread.currentThread().getName()+" get"+first);
                        System.out.println(Thread.currentThread().getName()+" get"+second);
                    }
                }
            }
        }
    }
}

两种解决方式

内部通过顺序比较,确定拿锁的顺序;

java 复制代码
/**
 *类说明:不会产生死锁
 */
public class SafeOperate {

    private static Object No1 = new Object();//第一个锁
    private static Object No2 = new Object();//第二个锁
    private static Object tieLock = new Object();//第三把锁

    public void transfer(Object first,Object second)
            throws InterruptedException {

        int firstHash = System.identityHashCode(first);
        int secondHash = System.identityHashCode(second);

        if(firstHash<secondHash){
            synchronized (first){
                System.out.println(Thread.currentThread().getName()+" get "+first);
                Thread.sleep(100);
                synchronized (second){
                    System.out.println(Thread.currentThread().getName()+" get "+second);
                }
            }
        }else if(secondHash<firstHash){
            synchronized (second){
                System.out.println(Thread.currentThread().getName()+" get"+second);
                Thread.sleep(100);
                synchronized (first){
                    System.out.println(Thread.currentThread().getName()+" get"+first);
                }
            }
        }else{
            synchronized (tieLock){
                synchronized (first){
                    synchronized (second){
                        System.out.println(Thread.currentThread().getName()+" get"+first);
                        System.out.println(Thread.currentThread().getName()+" get"+second);
                    }
                }
            }
        }
    }
}

采用尝试拿锁的机制。

java 复制代码
/**
 *类说明:演示普通账户的死锁和解决
 */
public class TryLock {
    
    private static Lock No1 = new ReentrantLock();//第一个锁
    private static Lock No2 = new ReentrantLock();//第二个锁

    // 先尝试拿No1 锁,再尝试拿No2锁,No2 锁没拿到,连同No1 锁一起释放掉
    private static void zhangsanDo() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        Random r = new Random();
        while(true){
            if(No1.tryLock()){
                System.out.println(threadName +" get 1");
                try{
                    if(No2.tryLock()){
                        try{
                            System.out.println(threadName  +" get 2");
                            System.out.println("zhangsanDo do work------------");
                            break;
                        }finally{
                            No2.unlock();
                        }
                    }
                }finally {
                    No1.unlock();
                }

            }
            //Thread.sleep(r.nextInt(3));
        }
    }

    //先尝试拿No2锁,再尝试拿No1锁,No1锁没拿到,连同No2锁一起释放掉
    private static void lisiDo() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        Random r = new Random();
        while(true){
            if(No2.tryLock()){
                System.out.println(threadName +" get 2");
                try{
                    if(No1.tryLock()){
                        try{
                            System.out.println(threadName +" get 1");
                            System.out.println("lisiDo do work------------");
                            break;
                        }finally{
                            No1.unlock();
                        }
                    }
                }finally {
                    No2.unlock();
                }

            }
            //Thread.sleep(r.nextInt(3));
        }
    }

    private static class ZhangSan extends Thread{

        private String name;

        public ZhangSan(String name) {
            this.name = name;
        }

        public void run(){
            Thread.currentThread().setName(name);
            try {
                zhangsanDo();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        Thread.currentThread().setName("Monkey");
        ZhangSan zhangSan = new ZhangSan("ZhouYu");
        zhangSan.start();
        try {
            lisiDo();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

参见代码 cn.tulingxueyuan.safe.dl.TryLock 和 SafeOperate

其他安全问题

活锁

两个线程在尝试拿锁的机制中, 发生多个线程之间互相谦让, 不断发生同一 个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到, 而将本来已经持有 的锁释放的过程。

解决办法:每个线程休眠随机数,错开拿锁的时间。

线程饥饿

低优先级的线程,总是拿不到执行时间

线程安全的单例模式

在设计模式中, 单例模式是比较常见的一种设计模式, 如何实现单例呢? 一 种比较常见的是双重检查锁定。

双重检查锁定

上面的双重检查锁定却存在着线程安全问题,为什么呢?这是因为

singleDcl = new SingleDcl();

虽然只有一行代码,但是其实在具体执行的时候有好几步操作: 1 、JVM 为 SingleDcl 的对象实例在内存中分配空间

2、进行对象初始化,完成 new 操作

3 、JVM 把这个空间的地址赋给我们的引用 singleDcl

因为 JVM 内部的实现原理(指并发相关的重排序等, 后面的课程会学到) , 会产生一种情况,第 3 步会在第 2 步之前执行。

于是在多线程下就会产生问题:A 线程正在 syn 同步块中执行 singleDcl = new SingleDcl(),此时 B 线程也来执行 getInstance(),进行了 singleDcl == null 的检查, 因为第 3 步会在第 2 步之前执行, B 线程检查发现 singleDcl 不为 null ,会直接拿 着 singleDcl 实例使用, 但是这时 A 线程还在执行对象初始化,这就导致 B 线程 拿到的 singleDcl 实例可能只初始化了一半,B 线程访问 singleDcl 实例中的对象域 就很有可能出错。

怎么解决这个问题呢? 在前面声明 singleDcl 的位置:

private static SingleDcl singleDcl;

加上 volatile 关键字,变成 private volatile static SingleDcl singleDcl; 即可。

为何加上 volatile 关键字就行了呢,后面的课程在讲述JMM(Java 内存模型) 和 volatile 的原理会讲到。

单例模式推荐实现

懒汉式

类初始化模式,也叫延迟占位模式。在单例类的内部由一个私有静态内部类 来持有这个单例类的实例。 因为在 JVM 中, 对类的加载和类初始化,由虚拟机 保证线程安全。

延迟占位模式还可以用在多线程下实例域的延迟赋值。

饿汉式

在声明的时候就 new 这个类的实例,或者使用枚举也可以。

相关推荐
Sirius Wu1 小时前
Maven环境如何正确配置
java·maven
健康平安的活着2 小时前
java之 junit4单元测试Mockito的使用
java·开发语言·单元测试
Java小白程序员2 小时前
Spring Framework :IoC 容器的原理与实践
java·后端·spring
xuTao6673 小时前
Easy Rules 规则引擎详解
java·easy rules
m0_480502644 小时前
Rust 入门 KV存储HashMap (十七)
java·开发语言·rust
杨DaB4 小时前
【SpringBoot】Swagger 接口工具
java·spring boot·后端·restful·swagger
YA3334 小时前
java基础(九)sql基础及索引
java·开发语言·sql
桦说编程4 小时前
方法一定要有返回值 \ o /
java·后端·函数式编程
小李是个程序5 小时前
登录与登录校验:Web安全核心解析
java·spring·web安全·jwt·cookie
David爱编程5 小时前
Java 创建线程的4种姿势,哪种才是企业级项目的最佳实践?
java·后端