Java并发编程 并发安全劲敌 线程死锁 以及其他线程安全问题

死锁

概念

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

举个例子:
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,谁就可以进行下去,另外一个没抢到的,就等着, 这种情况下,也不会产生死锁。

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

学术化的定义

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

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

理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。只要打破四个必要条件之一就能有效预防死锁的发生:打 破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。打破不可抢 占条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出 原占有的资源。打破占有且申请条件:采用资源预先分配策略,即进程运行前申 请全部资源,满足则运行,不然就等待,这样就不会占有且申请。打破循环等待 条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按 序号递增的形式申请资源。

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

现象、危害和解决

在我们 IT 世界有没有存在死锁的情况,有:数据库里多事务而且要同时操 作多个表的情况下。所以数据库设计的时候就考虑到了检测死锁和从死锁中恢复 的机制。比如 oracle 提供了检测和处理死锁的语句,而 mysql 也提供了“循环依赖检测的机制”
在这里插入图片描述
在这里插入图片描述
在 Java 世界里存在着多线程争夺多个资源,不可避免的存在着死锁。那么我们在编写代码的时候什么情况下会发生呢?

现象

  • 简单顺序死锁
/**
 *类说明:演示普通账户的死锁和解决
 */
public class NormalDeadLock {
    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+" get 1st");
            Thread.sleep(100);
            synchronized (valueSecond){
                System.out.println(threadName+" get 2nd");
            }
        }
    }

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

    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();
        }
    }
}
  • 动态顺序死锁

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

危害

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

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

解决

  • 定位:
    要解决死锁,当然要先找到死锁,怎么找?
    通过 jps 查询应用的 id,再通过 jstack id 查看应用的锁的持有情况
    在这里插入图片描述
  • 修正:
    关键是保证拿锁的顺序一致 两种解决方式
    1、内部通过顺序比较,确定拿锁的顺序;
    2、采用尝试拿锁的机制。

参见代码

/**
 *
 *类说明:银行转账动作接口
 */
public interface ITransfer {
    void transfer(UserAccount from, UserAccount to, int amount)
    		throws InterruptedException;
}
/**
 *
 *类说明:用户账户的实体类
 */
public class UserAccount {
    ///private int id;
    private final String name;//账户名称
    private int money;//账户余额

    private final Lock lock = new ReentrantLock();

    public Lock getLock() {
        return lock;
    }

    public UserAccount(String name, int amount) {
        this.name = name;
        this.money = amount;
    }

    public String getName() {
        return name;
    }

    public int getAmount() {
        return money;
    }

    @Override
    public String toString() {
        return "UserAccount{" +
                "name='" + name + '\'' +
                ", money=" + money +
                '}';
    }

    //转入资金
    public void addMoney(int amount){
        money = money + amount;
    }

    //转出资金
    public void flyMoney(int amount){
        money = money - amount;
    }
}
/**
 *
 *类说明:不会产生死锁的安全转账
 */
public class SafeOperate implements ITransfer {

    private static Object tieLock = new Object();//第三把锁

    @Override
    public void transfer(UserAccount from, UserAccount to, int amount)
            throws InterruptedException {

        int fromHash = System.identityHashCode(from);
        int toHash = System.identityHashCode(to);

        if(fromHash<toHash){
            synchronized (from){
                System.out.println(Thread.currentThread().getName()+" get "+from.getName());
                Thread.sleep(100);
                synchronized (to){
                    System.out.println(Thread.currentThread().getName()+" get "+to.getName());
                    from.flyMoney(amount);
                    to.addMoney(amount);
                    System.out.println(from);
                    System.out.println(to);
                }
            }
        }else if(toHash<fromHash){
            synchronized (to){
                System.out.println(Thread.currentThread().getName()+" get"+to.getName());
                Thread.sleep(100);
                synchronized (from){
                    System.out.println(Thread.currentThread().getName()+" get"+from.getName());
                    from.flyMoney(amount);
                    to.addMoney(amount);
                    System.out.println(from);
                    System.out.println(to);
                }
            }
        }else{
            synchronized (tieLock){
                synchronized (from){
                    synchronized (to){
                        from.flyMoney(amount);
                        to.addMoney(amount);
                    }
                }
            }
        }
    }
}
/**
 *
 *类说明:不会产生死锁的安全转账第二种方法
 */
public class SafeOperateToo implements ITransfer {

    @Override
    public void transfer(UserAccount from, UserAccount to, int amount)
            throws InterruptedException {
        Random r = new Random();
        while(true){
            if(from.getLock().tryLock()){
                System.out.println(Thread.currentThread().getName()
                        +" get"+from.getName());
                try{
                    if(to.getLock().tryLock()){
                        try{
                            System.out.println(Thread.currentThread().getName()
                                    +" get"+to.getName());
                            from.flyMoney(amount);
                            to.addMoney(amount);
                            System.out.println(from);
                            System.out.println(to);
                            break;
                        }finally{
                            to.getLock().unlock();
                        }
                    }
                }finally {
                    from.getLock().unlock();
                }

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

    }
}
/**
 *
 *类说明:模拟支付公司转账的动作
 */
public class PayCompany {

	/*执行转账动作的线程*/
    private static class TransferThread extends Thread{

        private String name;
        private UserAccount from;
        private UserAccount to;
        private int amount;
        private ITransfer transfer;

        public TransferThread(String name, UserAccount from, UserAccount to,
                              int amount, ITransfer transfer) {
            this.name = name;
            this.from = from;
            this.to = to;
            this.amount = amount;
            this.transfer = transfer;
        }


        @Override
        public void run(){
            Thread.currentThread().setName(name);
            try {
                transfer.transfer(from,to,amount);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }

    public static void main(String[] args) {
        PayCompany payCompany = new PayCompany();
        UserAccount zhangsan = new UserAccount("zhangsan",20000);
        UserAccount lisi = new UserAccount("lisi",20000);
        ITransfer transfer = new SafeOperateToo();
        TransferThread zhangsanToLisi = new TransferThread("zhangsanToLisi"
                ,zhangsan,lisi,2000,transfer);
        TransferThread lisiToZhangsan = new TransferThread("lisiToZhangsan"
                ,lisi,zhangsan,4000,transfer);
        zhangsanToLisi.start();
        lisiToZhangsan.start();

    }

}

其他安全问题

活锁

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

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

线程饥饿

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

并发下的性能

使用并发的目标是为了提高性能,引入多线程后,其实会引入额外的开销, 如线程之间的协调、增加的上下文切换,线程的创建和销毁,线程的调度等等。 过度的使用和不恰当的使用,会导致多线程程序甚至比单线程还要低。
衡量应用的程序的性能:服务时间,延迟时间,吞吐量,可伸缩性等等,其 中服务时间,延迟时间(多快),吞吐量(处理能力的指标,完成工作的多少)。 多快和多少,完全独立,甚至是相互矛盾的。
对服务器应用来说:多少(可伸缩性,吞吐量)这个方面比多快更受重视。
我们做应用的时候:

  1. 先保证程序正确,确实达不到要求的时候,再提高速度。(黄金原则)
  2. 一定要以测试为基准。

线程引入的开销

上下文切换

如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可 运行的线程数大于 CPU 的数量,那么操作系统最终会将某个正在运行的线程调度 出来,从而使其他线程能够使用 CPU。这将导致一次上下文切换,在这个过程中将 保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当 前上下文。上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们 需要记住每本书当前读到的页码。

切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和 JVM 共享的数据结构。应用程序、操作系统以及 JVM 都使用一组相同的 CPU。 在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟 周期就越少。但上下文切换的开销并不只是包含 JVM 和操作系统的开销。当一 个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因 此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。

当线程由于等待某个发生竞争的锁而被阻塞时,JVM 通常会将这个线程挂起, 并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调 度时间片。在程序中发生越多的阻塞(包括阻塞 IO,等待获取发生竞争的锁,或者在 条件变量上等待),与 CPU 密集型的程序就会发生越多的上下文切换,从而增加调 度开销,并因此而降低吞吐量。

上下文切换是计算密集型操作。也就是说,它需要相当可观的处理器时间。 所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操 作系统中时间消耗最大的操作。上下文切换的实际开销会随着平台的不同而变化, 然而按照经验来看:在大多数通用的处理器中,上下文切换的开销相当于 50~10000 个时钟周期,也就是几微秒。

UNIX系统的 vmstat命令能报告上下文切换次数以及在内核中执行时间所占 比例等信息。如果内核占用率较高(超过 10%),那么通常表示调度活动发生得很频 繁,这很可能是由 IO 或竞争锁导致的阻塞引起的。

内存同步
  1. 同步操作的性能开销包括多个方面。在 synchronized 和 volatile 提供的可见 性保证中可能会使用一些特殊指令,即内存栅栏(MemoryBarrier)。
  2. 内存栅栏可以刷新缓存,使缓存无效刷新硬件的写缓冲,以及停止执行管道。
  3. 内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。
阻塞
  1. 引起阻塞的原因:包括阻塞 IO,等待获取发生竞争的锁,或者在条件变量上等待等等。
  2. 阻塞会导致线程挂起【挂起:挂起进程在操作系统中可以定义为暂时被淘汰 出内存的进程,机器的资源是有限的,在资源不足的情况下,操作系统对在内存 中的程序进行合理的安排,其中有的进程被暂时调离出内存,当条件允许的时候, 会被操作系统再次调回内存,重新进入等待被执行的状态即就绪态,系统在超过 一定的时间没有任何动作】。
  3. 很明显这个操作至少包括两次额外的上下文切换,还有相关的操作系统级的 操作等等。

如何减少锁的竞争

减少锁的粒度

使用锁的时候,锁所保护的对象是多个,当这些多个对象其实是独立变化的 时候,不如用多个锁来一一保护这些对象。但是如果有同时要持有多个锁的业务 方法,要注意避免发生死锁

缩小锁的范围

对锁的持有实现快进快出,尽量缩短持由锁的的时间。将一些与锁无关的代 码移出锁的范围,特别是一些耗时,可能阻塞的操作

避免多余的锁

两次加锁之间的语句非常简单,导致加锁的时间比执行这些语句还长,这个 时候应该进行锁粗化—扩大锁的范围。

锁分段

ConcurrrentHashMap 就是典型的锁分段。

替换独占锁

在业务允许的情况下:

  1. 使用读写锁
  2. 用自旋 CAS
  3. 使用系统的并发容器

线程安全的单例模式

/**
 * 懒汉式-双重检查
 */
public class SingleDcl {
    private static SingleDcl singleDcl;
    private SingleDcl(){
    }

    public static SingleDcl getInstance(){
        if (singleDcl == null){ //第一次检查,不加锁
            System.out.println(Thread.currentThread()+" is null");
            synchronized(SingleDcl.class){ //加锁
                if (singleDcl == null){ //第二次检查,加锁情况下
                    System.out.println(Thread.currentThread()+" is null");
                    singleDcl = new SingleDcl();
                }
            }
        }
        return singleDcl;
    }
}

双重检查锁定

在这里插入图片描述
解决办法,加 volatile 关键字

解决之道
懒汉式

类初始化模式,也叫延迟占位模式。在单例类的内部由一个私有静态内部类 来持有这个单例类的实例。
延迟占位模式还可以用在多线程下实例域的延迟赋值。

饿汉式

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

发布了31 篇原创文章 · 获赞 33 · 访问量 1239

猜你喜欢

转载自blog.csdn.net/weixin_42081445/article/details/105374915