跳至主要內容

并发安全和死锁

IT王小二约 3305 字

并发安全和死锁

作者:IT王小二

博客:https://itwxe.comopen in new window

一、并发安全

1. 什么是线程安全性

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

那么怎么实现线程安全呢?可以使用下面的这些方式。

2. 线程封闭

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

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

2、实现线程封闭有哪些方法呢?

  • ad-hoc 线程封闭:这是完全靠实现者控制的线程封闭,他的线程封闭完全靠实现者实现。Ad-hoc线程封闭非常脆弱,应该尽量避免使用。
  • 栈封闭:栈封闭是我们编程当中遇到的最多的线程封闭。什么是栈封闭呢?简单的说就是局部变量,多个线程访问一个方法,此方法中的局部变量都会被拷贝一份到线程栈中,所以局部变量是不被多个线程所共享的,也就不会出现并发问题,所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。

3. 无状态的类

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

那么下面这个类的方法参数使用了对象,也是线程安全的吗?

public class StatelessClass {
    public int service(int a,int b){
	    return a + b;
    }

    public void serviceUser(UserVo user){
        
    }
}

当然也是,这是为什么呢?多线程使用的情况下,这个user对象的实例确实会不正常,但是对于StatelessClass这个类来说,它并不持有UserVo的对象实例,它自己本身并不会出问题,有问题的是UserVo这个类,而非StatelessClass本身。

4. 让类不可变

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

2、根本就不提供任何可供修改成员变量的地方,同时成员变量也不作为方法的返回值。

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

public class ImmutableClass {
    private final int a;
    private final int b;
    /**
     * 加上这个就不保证安全了
     */
    private final UserVo user = new UserVo();

    public int getA() {
        return a;
    }

    public UserVo getUser() {
        return user;
    }

    public ImmutableClass(int a, int b) {
        this.a = a;
        this.b = b;
    }

    public static class User {
        private int age;

        public int getAge() {
            return age;
        }

        public void setAge(int age) {
            this.age = age;
        }
    }
}

5. volatile

volatile可以说是java虚拟机提供的最轻量级的同步机制,但是并不能保证类的线程安全性,只能保证类的可见性,最适合一个线程写,多个线程读的情景。

6. 加锁和CAS

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

7. 安全的发布

类中持有的成员变量,如果是基本类型,发布出去,并没有关系,因为发布出去的其实是这个变量的一个副本。

但是如果类中持有的成员变量是对象的引用,如果这个成员对象不是线程安全的,通过 get 等方法发布出去,会造成这个成员对象本身持有的数据在多线程下不正确的修改,从而造成整个类线程不安全的问题。

那么怎么保证发布对象多线程下的安全呢?我们可以参考Collections.synchronizedList方法来实现我们自己发布对象的安全性,查看源码可以知道是通过synchronized关键字来实现的:

private List<Integer> list = Collections.synchronizedList(new ArrayList<>(3));

接下来我们就模仿一下发布一个安全的类。

public final class FinalUserVo {
    private int age;

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

public class SafePublicFinalUser {
    private final SynFinalUser user;

    public SynFinalUser getUser() {
        return user;
    }

    public SafePublicFinalUser(FinalUserVo user) {
        this.user = new SynFinalUser(user);
    }

    public static class SynFinalUser {
        private final FinalUserVo userVo;
        private final Object lock = new Object();

        public SynFinalUser(FinalUserVo userVo) {
            this.userVo = userVo;
        }

        public int getAge() {
            synchronized (lock) {
                return userVo.getAge();
            }
        }

        public void setAge(int age) {
            synchronized (lock) {
                userVo.setAge(age);
            }
        }
    }
}

8. TheadLocal

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

其实本质还是可以认为是将ThreadLocal封闭的对象,将副本存储在了每个线程中,还是每个线程的局部变量,互不影响。

9. Servlet 辨析

Servlet不是一个线程安全的类,但是为什么我们用Servlet的时候没有感觉到呢,因为在需求上每个Servlet中很少有共享需求,接收到了请求,处理时都是一个单独的线程来负责。

但是如果 Servlet 中有成员变量,一旦有多线程下的写,就很容易产生线程安全问题。

三、死锁

1. 概念

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

你没看错,死锁并不止于线程,说进程也没毛病,因为,死锁往往是各进程之间的线程相互抢夺资源导致的。

总结下来就是:死锁是必然发生在多操作者(M>=2 个)情况下,争夺多个资源(N>=2 个, 且 N<=M)才会发生这种情况。

2. 死锁现象

简单顺序死锁

由于锁顺序定义不一致导致死锁。

示例:

public class SimpleDeadLock {
    /**
     * 第一个锁
     */
    private static Object valueFirst = new Object();
    /**
     * 第二个锁
     */
    private static Object valueSecond = new Object();

    /**
     * 先拿第一个锁,再拿第二个锁
     */
    private static void fisrtToSecond() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        synchronized (valueFirst) {
            System.out.println(threadName + " 拿到了第一把锁");
            Thread.sleep(100);
            synchronized (valueSecond) {
                System.out.println(threadName + " 拿到了第二把锁");
            }
        }
    }

    /**
     * 先拿第二个锁,再拿第一个锁
     */
    private static void SecondToFisrt() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        synchronized (valueSecond) {
            System.out.println(threadName + " 拿到了第二把锁");
            Thread.sleep(100);
            synchronized (valueFirst) {
                System.out.println(threadName + " 拿到了第一把锁");
            }
        }
    }

    private static class TestThread extends Thread {

        private String name;

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

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

    public static void main(String[] args) {
        Thread.currentThread().setName("TestDeadLock");
        TestThread testThread = new TestThread("SubTestThread");
        testThread.start();
        try {
            fisrtToSecond();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

这种简单的顺序死锁看到代码一眼就可以看出来,解决办法也很简单,只要把锁的顺序保持一致就行了。

动态顺序死锁

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

比如:两个银行账户之间在同一时间相互转账,下面仅为转账动作代码。

/**
 * 转账动作代码,省略判断银行账户剩余金额
 * @param from 转出银行账户
 * @param to 转入银行账户
 * @param amount 转账金额
 * @throws InterruptedException 中断异常
 */
public void transfer(UserAccount from, UserAccount to, int amount) 
        throws InterruptedException {
    // 锁定from账号
    synchronized (from) {
        System.out.println(Thread.currentThread().getName() + " get" + from.getName());
        Thread.sleep(100);
        // 锁定to账号
        synchronized (to) {
            System.out.println(Thread.currentThread().getName() + " get" + to.getName());
            // from转出金额
            from.flyMoney(amount);
            // to转入金额
            to.addMoney(amount);
        }
    }
}

线程A从X账户向Y账户转账,线程B从账户Y向账户X转账,这时候问题就产生了,两线程想要获取转入账户的锁,但是分别被其他线程持有,这时候死锁就产生了。

那么怎么解决呢?

有两种解决方案(思路仅供参考),当然不排除大佬们有更好的解决方案。

  • 第一种,使用hash值来确定执行顺序,如果hash值相等的话就用第三把锁来确定持有锁的顺序。
  • 第二种,使用显式锁,在 UserAccount 中定义 Lock,然后在转账方法中使用 tryLock() 尝试拿 form的锁,如果拿到了那么就再次尝试拿 to的锁,如果全部拿到了那么自然执行转账,如果没有拿到则释放自己拿到的锁。

3. 如果实际中出现了死锁怎么解决

死锁的危害

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

实际工作中的死锁

在实际工作中,如果测试没有并发测试,大多数情况下可能死锁无法复现,那如果真的出现了死锁要怎么排查解决呢?

解决办法

以上述的简单顺序死锁案例为例

1、首先使用 jps 命令来查询应用id,然后通过 jstack id 定位 锁的持有情况。

死锁定位id

定位死锁问题1

定位死锁问题2

定位死锁问题3

2、定位到了问题了,那么就要 修正 了,通常都是保证拿锁的顺序一致,通常两种解决方案。

  • 通过内部比较,确定拿锁的顺序。
  • 采用尝试拿锁的机制。

4. 其他的概念

活锁

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

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

线程饥饿

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

5. 线程安全的单例模式

饿汉模式

在声明的时候就 new 这个类的实例,因为在 JVM 中,对类的加载和类初始化,由虚拟机保证线程安全。

懒汉模式

饿汉模式下双重检查锁定能够保证安全吗?假设代码如下:

public class SingleHungry {
    private static SingleHungry singleHungry;
    private UserVo userVo;
    // 私有化
    private SingleHungry(){
    }

    public static SingleHungry getInstance(){
        if (singleHungry == null){
            // 第一次检查,不加锁
            synchronized(SingleHungry.class){
                // 加锁
                if (singleHungry == null){
                    // 第二次检查,加锁情况下
                    singleHungry = new SingleHungry();
                }
            }
        }
        return singleHungry;
    }
}

咋一看上去,没什么毛病,稳得很,我以前也一直都这么用过来的,后来看了JVM后相关知识发现并不对。。。

为什么不对呢?

就我目前的认知来说,可以从 jvm对象的创建过程 和 重排序 的两个层面来说明这个问题。

  • 我就从jvm对象的创建过程来说明这个问题,两个线程同时来获取这个单例的实例时,比如A线程已经执行到了new SingleHungry() 。
  • 对象创建过程包括:检查加载 -> 分配内存 -> 内存空间初始化 -> 设置 -> 对象的初始化。
  • 当执行new关键字的时候首先给这个对象执行了 检查加载 -> 分配内存 -> 内存空间初始化 -> 设置,此时,singleHungry 已经有了指向引用不为null了,但是他的属性(比如示例中的user)此时还未初始化,还是为null 。
  • 这时,B线程执行 getInstance() 方法后发现实例不为空了,然后获取到了 singleHungry 使用,然后 singleHungry.getUser().getName(),发现报 NullPointException 了。

解决办法:

  1. 使用饿汉模式。
  2. 或者懒汉模式的 singleHungry 加上 volatile 关键字。