Java并发CAS详解与并发安全问题

目录

一、什么是原子操作?

二、如何实现原子操作?

三、CAS实现原子操作的三大问题

ABA问题

循环时间长开销大

只能保证一个共享变量的原子操作

JDK中相关原子操作类的使用

AtomicInteger

AtomicIntegerArray

更新引用类型

AtomicReference

AtomicStampedReference

AtomicMarkableReference

原子更新字段类

AtomicIntegerFieldUpdater

AtomicLongFieldUpdater

AtomicReferenceFieldUpdater

LongAdder

LongAdder 原子类以及同步锁性能测试

其他新增

四、并发安全问题

线程安全性

如何实现呢?

线程封闭

栈封闭

TheadLocal

无状态的类

让类不可变

加锁和CAS

死锁

概念

学术化的定义

现象、危害和解决

实际工作中的死锁

五、其他安全问题

活锁

线程饥饿

六、线程安全的单例模式

双重检查锁定

单例模式推荐实现

懒汉式

饿汉式


一、什么是原子操作?

        什么是原子性?事务的一大特性就是原子性(事务具有ACID四大特性),一个事务包含多个操作,这些操作要么全部执行,要么全都不执行。

        并发里的原子性和原子操作是一样的内涵和概念,假定有两个操作A和B都包含多个步骤,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,执行B的线程看A的操作也是一样的,那么A和B对彼此来说是原子的。

二、如何实现原子操作?

        实现原子操作可以使用锁,锁机制,满足基本的需求是没有问题的了,但是有的时候我们的需求并非这么简单,我们需要更有效,更加灵活的机制,synchronized关键字是基于阻塞的锁机制,也就是说当一个线程拥有锁的时候,访问同一资源的其它线程需要等待,直到该线程释放锁,

        这里会有些问题:首先,如果被阻塞的线程优先级很高很重要怎么办?其次,如果获得锁的线程一直不释放锁怎么办?同时,还有可能出现一些例如死锁之类的情况,最后,其实锁机制是一种比较粗糙,粒度比较大的机制,相对于像计数器这样的需求有点儿过于笨重。为了解决这个问题,Java提供了Atomic系列的原子操作类。

        这些原子操作类其实是使用当前的处理器基本都支持CAS的指令,比如Intel的汇编指令cmpxchg,每个厂家所实现的具体算法并不一样,但是原理基本一样。每一个CAS操作过程都包含三个运算符:一个内存地址V,一个期望的值A和一个新值B,操作的时候如果这个地址上存放的值等于这个期望的值A,则将地址上的值赋为新值B,否则不做任何操作。

        CAS的基本思路就是,如果这个地址上的值和期望的值相等,则给其赋予新值,否则不做任何事儿,但是要返回原值是多少。自然CAS操作执行完成时,在业务上不一定完成了,这个时候我们就会对CAS操作进行反复重试,于是就有了循环CAS。很明显,循环CAS就是在一个循环里不断的做cas操作,直到成功为止。Java中的Atomic系列的原子操作类的实现则是利用了循环CAS来实现。

三、CAS实现原子操作的三大问题

ABA问题

        因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。

        ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。举个通俗点的例子,你倒了一杯水放桌子上,干了点别的事,然后同事把你水喝了又给你重新倒了一杯水,你回来看水还在,拿起来就喝,如果你不管水中间被人喝过,只关心水还在,这就是ABA问题。

        如果你是一个讲卫生讲文明的小伙子,不但关心水在不在,还要在你离开的时候水被人动过没有,因为你是程序员,所以就想起了放了张纸在旁边,写上初始值0,别人喝水前麻烦先做个累加才能喝水。

循环时间长开销大

        自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。

只能保证一个共享变量的原子操作

        当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。

         还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。

JDK中相关原子操作类的使用

AtomicInteger

  • int addAndGet(int delta):以原子方式将输入的数值与实例中的值(AtomicInteger里的value)相加,并返回结果。
  • boolean compareAndSet(int expect,int update):如果输入的数值等于预期值,则以原子方式将该值设置为输入的值。
  • int getAndIncrement():以原子方式将当前值加1,注意,这里返回的是自增前的值。
  • int getAndSet(int newValue):以原子方式设置为newValue的值,并返回旧值。
import java.util.concurrent.atomic.AtomicIntegerArray;
public class AtomicArray {
    static int[] value = new int[] { 1, 2 }; // 定义原始整数数组 value,初始值为 { 1, 2 }
    static AtomicIntegerArray ai = new AtomicIntegerArray(value); // 使用原始数组 value 初始化原子整数数组 ai

    public static void main(String[] args) {
        ai.getAndSet(0, 3); // 使用 getAndSet 方法将索引为 0 的元素值设置为 3,并返回原来索引 0 处的值
        System.out.println(ai.get(0)); // 打印索引为 0 的元素值,输出 3,因为之前已经将其设置为了 3
        System.out.println(value[0]); // 打印原始数组 value 的索引 0 处的值,输出 1,原始数组不会因为 ai 的修改而改变
    }
}
/**
 *类说明:演示基本类型的原子操作类
 */
public class UseAtomicInt {
    static AtomicInteger ai = new AtomicInteger(10);

    public static void main(String[] args) {
        ai.getAndIncrement();// 获取当前值,并递增
        ai.incrementAndGet();// 递增,并返回递增后的值
        //ai.compareAndSet();// 比较并设置,可以用于实现无锁算法,需要传入预期值和更新值
        ai.addAndGet(24);// 增加指定的值,并返回增加后的结果
    }
}

AtomicIntegerArray

        主要是提供原子的方式更新数组里的整型,其常用方法如下:

  • int addAndGet(int i,int delta):以原子方式将输入值与数组中索引i的元素相加。
  • boolean compareAndSet(int i,int expect,int update):如果当前值等于预期值,则以原子方式将数组位置i的元素设置成update值。

        需要注意的是,数组value通过构造方法传递进去,然后AtomicIntegerArray会将当前数组复制一份,所以当AtomicIntegerArray对内部的数组元素进行修改时,不会影响传入的数组。

更新引用类型

        原子更新基本类型的AtomicInteger,只能更新一个变量,如果要原子更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic包提供了以下3个类:

AtomicReference

        原子更新引用类型。

/**
 *类说明:演示引用类型的原子操作类
 * 演示如何使用AtomicReference进行引用类型的原子操作,确保在并发环境中的安全性
 */
public class UseAtomicReference {
    // 定义一个AtomicReference对象,用于存储UserInfo类型的引用
    static AtomicReference<UserInfo> atomicUserRef;

    public static void main(String[] args) {
        UserInfo user = new UserInfo("mouse", 6);//要修改的实体的实例
        atomicUserRef = new AtomicReference(user);// 初始化AtomicReference对象
        UserInfo updateUser = new UserInfo("rabbit",5);// 新的UserInfo实例,用于更新

        // 使用compareAndSet方法,如果当前值为user,则将其更新为updateUser
        atomicUserRef.compareAndSet(user,updateUser);
        // 输出AtomicReference中的值和原始user对象的值
        System.out.println(atomicUserRef.get());
        System.out.println(user);
    }
    
    //定义一个实体类
    static class UserInfo {
        private volatile String name;// 使用volatile修饰,确保多线程环境下的可见性
        private int age;
        public UserInfo(String name, int age) {
            this.name = name;
            this.age = age;
        }
        public String getName() {
            return name;
        }
        public int getAge() {
            return age;
        }

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

}

//运行结果
UserInfo{name='rabbit', age=5}
UserInfo{name='mouse', age=6}

AtomicStampedReference

        利用版本戳的形式记录了每次改变以后的版本号,这样的话就不会存在ABA问题了。这就是AtomicStampedReference的解决方案。AtomicMarkableReference跟AtomicStampedReference差不多, AtomicStampedReference是使用pair的int stamp作为计数器使用,AtomicMarkableReference的pair使用的是boolean mark。 还是上边说的那个水的例子,AtomicStampedReference可能关心的是动过几次,AtomicMarkableReference关心的是有没有被人动过,方法都比较简单。

/**
 *类说明:演示带版本戳的原子操作类
 */
public class UseAtomicStampedReference {
    // 创建一个AtomicStampedReference对象,初始值为"bubble",版本号为0
    static AtomicStampedReference<String> asr = new AtomicStampedReference("bubble",0);

    public static void main(String[] args) throws InterruptedException {
        //拿到当前的版本号(旧)
        final int oldStamp = asr.getStamp();
        final String oldReference = asr.getReference();
        System.out.println(oldReference+"============"+oldStamp);

        // 定义一个线程,使用正确的版本号进行更新
        Thread rightStampThread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName()+":当前变量值:"
                        +oldReference + "-当前版本戳:" + oldStamp + "-"
                  + asr.compareAndSet(oldReference,
                        oldReference + "+Java", oldStamp,
                        oldStamp + 1));
            }
        });

        // 定义一个线程,使用错误的版本号进行更新
        Thread errorStampThread = new Thread(new Runnable() {
            @Override
            public void run() {
                String reference = asr.getReference();
                System.out.println(Thread.currentThread().getName()
                        +":当前变量值:"
                        +reference + "-当前版本戳:" + asr.getStamp() + "-"
                        + asr.compareAndSet(reference,
                        reference + "+C", oldStamp,
                        oldStamp + 1));
            }
        });
        // 启动并等待线程完成
        rightStampThread.start();
        rightStampThread.join();
        errorStampThread.start();
        errorStampThread.join();

        // 输出最终的值和版本号
        System.out.println(asr.getReference()+"============"+asr.getStamp());
    }
}

//运行结果
bubble============0
Thread-0:当前变量值:bubble-当前版本戳:0-true
Thread-1:当前变量值:bubble+Java-当前版本戳:1-false
bubble+Java============1

AtomicMarkableReference

        原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和引用类型。构造方法是AtomicMarkableReference(V initialRef,booleaninitialMark)。

原子更新字段类

如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic包提供了以下3个类进行原子字段更新。

要想原子地更新字段类需要两步。第一步,因为原子更新字段类都是抽象类,每次使用的时候必须使用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新类的字段(属性)必须使用public volatile修饰符。

AtomicIntegerFieldUpdater

        原子更新整型的字段的更新器。

AtomicLongFieldUpdater

        原子更新长整型字段的更新器。

AtomicReferenceFieldUpdater

        原子更新引用类型里的字段。

LongAdder

        JDK1.8时,java.util.concurrent.atomic包中提供了一个新的原子类:LongAdder。 根据Oracle官方文档的介绍,LongAdder在高并发的场景下会比它的前辈AtomicLong 具有更好的性能,代价是消耗更多的内存空间。

        AtomicLong是利用了底层的CAS操作来提供并发性的,调用了Unsafe类的getAndAddLong方法,该方法是个native方法,它的逻辑是采用自旋的方式不断更新目标值,直到更新成功。

        在并发量较低的环境下,线程冲突的概率比较小,自旋的次数不会很多。但是,高并发环境下,N个线程同时进行自旋操作,会出现大量失败并不断自旋的情况,此时AtomicLong的自旋会成为瓶颈。

        这就是LongAdder引入的初衷——解决高并发环境下AtomicLong的自旋瓶颈问题。

        AtomicLong中有个内部变量value保存着实际的long值,所有的操作都是针对该变量进行。也就是说,高并发环境下,value变量其实是一个热点,也就是N个线程竞争一个热点。

        LongAdder的基本思路就是分散热点,将value值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。如果要获取真正的long值,只要将各个槽中的变量值累加返回。

        这种做法和ConcurrentHashMap中的“分段锁”其实就是类似的思路。

        LongAdder提供的API和AtomicLong比较接近,两者都能以原子的方式对long型变量进行增减。

        但是AtomicLong提供的功能其实更丰富,尤其是addAndGet、decrementAndGet、compareAndSet这些方法。

        addAndGet、decrementAndGet除了单纯的做自增自减外,还可以立即获取增减后的值,而LongAdder则需要做同步控制才能精确获取增减后的值。如果业务需求需要精确的控制计数,做计数比较,AtomicLong也更合适。

        另外,从空间方面考虑,LongAdder其实是一种“空间换时间”的思想,从这一点来讲AtomicLong更适合。

        总之,低并发、一般的业务场景下AtomicLong是足够了。如果并发量很多,存在大量写多读少的情况,那LongAdder可能更合适。适合的才是最好的,如果真出现了需要考虑到底用AtomicLong好还是LongAdder的业务场景,那么这样的讨论是没有意义的,因为这种情况下要么进行性能测试,以准确评估在当前业务场景下两者的性能,要么换个思路寻求其它解决方案。

  1. 高并发累加:当有多个线程同时对一个数值进行累加操作时,LongAdder比AtomicLong更适用。由于LongAdder内部采用了分段累加,因此在高并发场景下,性能更好。
  2. 避免竞争:LongAdder内部采用分段锁的方式,不同线程对不同计数器进行操作,减少了竞争,从而提高了性能。
  3. 累加频繁:如果累加操作非常频繁,LongAdder比AtomicLong更适用。因为LongAdder内部的计数器是分段的,频繁累加不会导致线程竞争问题,而AtomicLong在高并发情况下可能会出现较多的线程竞争。

对于LongAdder来说,内部有一个base变量,一个Cell[]数组。

base变量:非竞态条件下,直接累加到该变量上。

Cell[]数组:竞态条件下,累加个各个线程自己的槽Cell[i]中。

所以,最终结果的计算应该是

在实际运用的时候,只有从未出现过并发冲突的时候,base基数才会使用到,一旦出现了并发冲突,之后所有的操作都只针对Cell[]数组中的单元Cell。

        而LongAdder最终结果的求和,并没有使用全局锁,返回值不是绝对准确的,因为调用这个方法时还有其他线程可能正在进行计数累加,所以只能得到某个时刻的近似值,这也就是LongAdder并不能完全替代LongAtomic的原因之一。

        而且从测试情况来看,线程数越多,并发操作数越大,LongAdder的优势越大,线程数较小时,AtomicLong的性能还超过了LongAdder。

LongAdder 原子类以及同步锁性能测试
/**
 * 类说明:LongAdder,原子类以及同步锁性能测试
 */
public class LongAdderDemo {
    private static final int MAX_THREADS = 20;// 最大线程数
    private static final int TASK_COUNT = 400;// 任务数量
    private static final int TARGET_COUNT = 100000000;// 目标计数值

    //三个不同类型的long有关的变量
    private AtomicLong acount = new AtomicLong(0L);// 原子型long变量
    private LongAdder lacount = new LongAdder();// LongAdder变量
    private long count = 0;// 普通long变量

    //控制线程同时进行
    private static CountDownLatch cdlsync = new CountDownLatch(TASK_COUNT);
    private static CountDownLatch cdlatomic = new CountDownLatch(TASK_COUNT);
    private static CountDownLatch cdladdr = new CountDownLatch(TASK_COUNT);


    /*普通long的同步锁测试方法*/
    protected synchronized long inc() {
        return ++count;// 同步递增
    }
    /*获取当前值*/
    protected synchronized long getCount() {
        return count;
    }

    /*普通long的同步锁测试任务*/
    public class SyncTask implements Runnable {
        protected String name;
        protected long starttime;   // 任务开始时间
        LongAdderDemo out;          // 外部类引用

        public SyncTask(long starttime, LongAdderDemo out) {
            this.starttime = starttime;
            this.out = out;
        }

        @Override
        public void run() {
            long v = out.getCount();
            while (v < TARGET_COUNT) {
                v = out.inc();// 同步递增
            }
            long endtime = System.currentTimeMillis();
            System.out.println("SyncTask spend:" + (endtime - starttime) + "ms" );
            cdlsync.countDown();// 任务完成,计数器递减
        }
    }

    /*普通long的执行同步锁测试*/
    public void testSync() throws InterruptedException {
        // 创建线程池
        ExecutorService exe = Executors.newFixedThreadPool(MAX_THREADS);
        long starttime = System.currentTimeMillis();
        SyncTask sync = new SyncTask(starttime, this);
        for (int i = 0; i < TASK_COUNT; i++) {
            exe.submit(sync);// 提交任务
        }
        cdlsync.await();// 等待所有任务完成
        exe.shutdown();// 关闭线程池
    }

    /*原子型long的测试任务*/
    public class AtomicTask implements Runnable {
        protected String name;
        protected long starttime;// 任务开始时间

        public AtomicTask(long starttime) {
            this.starttime = starttime;
        }

        @Override
        public void run() {
            long v = acount.get();
            while (v < TARGET_COUNT) {
                v = acount.incrementAndGet();// 原子递增
            }
            long endtime = System.currentTimeMillis();
            System.out.println("AtomicTask spend:" + (endtime - starttime) + "ms" );
            cdlatomic.countDown();// 任务完成,计数器递减
        }
    }


    /*原子型long的执行测试*/
    public void testAtomic() throws InterruptedException {
        // 创建线程池
        ExecutorService exe = Executors.newFixedThreadPool(MAX_THREADS);
        long starttime = System.currentTimeMillis();
        AtomicTask atomic = new AtomicTask(starttime);
        for (int i = 0; i < TASK_COUNT; i++) {
            exe.submit(atomic);// 提交任务
        }
        cdlatomic.await();// 等待所有任务完成
        exe.shutdown();// 关闭线程池
    }

    /*LongAdder的测试任务*/
    public class LongAdderTask implements Runnable {
        protected String name;
        protected long startTime;// 任务开始时间

        public LongAdderTask(long startTime) {
            this.startTime = startTime;
        }

        @Override
        public void run() {
            long v = lacount.sum();
            while (v < TARGET_COUNT) {
                lacount.increment();// LongAdder递增
                v = lacount.sum();// 获取当前值
            }
            long endtime = System.currentTimeMillis();
            System.out.println("LongAdderTask spend:" + (endtime - startTime) + "ms");
            cdladdr.countDown();// 任务完成,计数器递减
        }

    }

    /*LongAdder的执行测试*/
    public void testLongAdder() throws InterruptedException {
        // 创建线程池
        ExecutorService exe = Executors.newFixedThreadPool(MAX_THREADS);
        long startTime = System.currentTimeMillis();
        LongAdderTask longAdderTask = new LongAdderTask(startTime);
        for (int i = 0; i < TASK_COUNT; i++) {
            exe.submit(longAdderTask);// 提交任务
        }
        cdladdr.await();// 等待所有任务完成
        exe.shutdown(); // 关闭线程池
    }

    public static void main(String[] args) throws InterruptedException {
        LongAdderDemo demo = new LongAdderDemo();
        demo.testSync();
        //demo.testAtomic();
        //demo.testLongAdder();
    }
}

其他新增

        除了新引入LongAdder外,还有引入了它的三个兄弟类:LongAccumulator、DoubleAdder、DoubleAccumulator。

        LongAccumulator是LongAdder的增强版。LongAdder只能针对数值的进行加减运算,而LongAccumulator提供了自定义的函数操作。

        通过LongBinaryOperator,可以自定义对入参的任意操作,并返回结果(LongBinaryOperator接收2个long作为参数,并返回1个long)。

        LongAccumulator内部原理和LongAdder几乎完全一样。

        DoubleAdder和DoubleAccumulator用于操作double原始类型。

四、并发安全问题

线程安全性

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

正如Java语言规范在《Chapter 17. Threads and Locks》所说的:Chapter 17. Threads and Locks

        图中标红文字的意思是:线程的行为(尤其是在未正确同步的情况下)可能会造成混淆并且违反直觉。本章描述了多线程程序的语义。它包括规则,通过读取多个线程更新的共享内存可以看到值。

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

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

如何实现呢?

线程封闭

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

        什么是线程封闭呢?

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

栈封闭

        栈封闭是我们编程当中遇到的最多的线程封闭。

        什么是栈封闭呢?

        简单的说就是局部变量。多个线程访问一个方法,此方法中的局部变量都会被拷贝一份到线程栈中。所以局部变量是不被多个线程所共享的,也就不会出现并发问题。所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。

TheadLocal

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

无状态的类

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

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

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

让类不可变

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

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

加锁和CAS

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

死锁

概念

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

        举个例子: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. 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  4. 环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。

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

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

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

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

现象、危害和解决

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

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

现象

简单顺序死锁

/*
*类说明:演示死锁的产生
*/
public class NormalDeadLock {

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

    //第一个拿锁的方法
    private static void rabbitDo() 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");
            }
        }

    }

    //第二个拿锁的方法synchronized ()No14、No13对换就解决了死锁问题
    private static void monkeyDo() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        synchronized (No14){
            System.out.println(threadName + " get No13");
            Thread.sleep(100);
            synchronized (No13){
                System.out.println(threadName + " get No14");
            }
        }
    }

    //子线程,代表兔子
    private static class Rabbit extends Thread{

        private String name;

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

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

    public static void main(String[] args) throws InterruptedException {
        //主线程
        Thread.currentThread().setName("Monkey");
        Rabbit rabbit= new Rabbit("Rabbit");
        //System.out.println(ManagementFactory.getRuntimeMXBean().getName());
        rabbit.start();
        monkeyDo();
    }

}

// 运行结果 死锁
Monkey get No13
Rabbit get No13

// 运行结果 把第二个拿锁的monkeyDo方法synchronized ()No14、No13对换就解决了死锁问题
Monkey get No13
Monkey get No14
Rabbit get No13
Rabbit get No14

动态顺序死锁

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

/*
*类说明:不自知的死锁
*/
public class DynDeadLock {

    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 Rabbit extends Thread{

        private String name;

        public Rabbit(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 {
        //主线程,代表Monkey
        Thread.currentThread().setName("Monkey");
        Rabbit rabbit = new Rabbit("Rabbit");
        rabbit.start();
        businessDo(No2,No1);//把No1和No2对换就解决了死锁问题
    }

}

//运行结果 死锁
Monkey get first
Rabbit get first

//把最后一行businessDo(No2,No1);//把No1和No2对换就解决了死锁问题
Monkey get first
Monkey get second
Rabbit get first
Rabbit get second

危害

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

实际工作中的死锁

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

解决

定位

        要解决死锁,当然要先找到死锁,怎么找?

        通过jps 查询应用的 id,再通过jstack id 查看应用的锁的持有情况

修正

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

两种解决方式

  • 内部通过顺序比较,确定拿锁的顺序;
  • 采用尝试拿锁的机制。
/*
*类说明:演示普通账户的死锁和解决
*/
public class TryLock {
    private static Lock No13 = new ReentrantLock();//第一个锁
    private static Lock No14 = new ReentrantLock();//第二个锁

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

            }
            //设置线程休眠随机数,错开拿锁的时间,有效地减少了不必要的资源竞争和锁争用,不加的话释放锁和拿锁的频率会增加,降低性能
            Thread.sleep(r.nextInt(3));
        }
    }

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

            }
            //设置线程休眠随机数,错开拿锁的时间,有效地减少了不必要的资源竞争和锁争用,不加的话释放锁和拿锁的频率会增加,降低性能
            Thread.sleep(r.nextInt(3));
        }
    }

    private static class Rabbit extends Thread{

        private String name;

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

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

    public static void main(String[] args) {
        Thread.currentThread().setName("Monkey");
        Rabbit rabbit = new Rabbit("Rabbit");
        rabbit.start();
        try {
            monkeyDo();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

// 运行结果
Monkey get 14
Rabbit get 13
Rabbit get 14
rabbitDo do work------------
Monkey get 14
Monkey get 13
monkeyDo do work------------
/*
*类说明:不会产生死锁
*/
public class SafeOperate {

    private static Object No13 = new Object();//第一个锁
    private static Object No14 = 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);
                    }
                }
            }
        }
    }
}

五、其他安全问题

活锁

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

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

线程饥饿

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

六、线程安全的单例模式

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

双重检查锁定

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

        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这个类的实例,或者使用枚举也可以。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值