深入Java多线程锁策略

Posted Putarmor

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入Java多线程锁策略相关的知识,希望对你有一定的参考价值。

乐观锁

乐观锁认为一般情况下不会发生并发冲突,只有在数据进行更新时候,才会去检测并发冲突,如果没有检测到冲突则直接进行数据修改,若有冲突则返回失败。

CAS机制

CAS:全称为Compare and swap,字面意思“比较并交换”,是乐观锁的一种机制。当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程会收到操作失败的信号,因此可以看出,CAS是乐观锁。

CAS组成部分:V:内存中的值
       A:预期的旧值
       B:新值

实现原理:如果V==A为true,此时没有并发冲突,将B赋值给V,成功修改;当V!=A,产生了并发冲突,进行自旋,将旧值A修改为内存中的值V,然后再进行比较。
在这里插入图片描述

乐观锁的实现

Atomic

使用Atomic相关类实现乐观锁,同时它可以解决线程不安全的问题。

示例:对变量count=0进行100次++操作和100次- -操作,去验证线程是否可以解决线程不安全。

public class TestDemo5 {

    private static AtomicInteger count = new AtomicInteger(0);
    //设置最大循环次数
    private static final int MAXSIZE = 100000;
    public static void main(String[] args) throws InterruptedException {
        //这里创建线程执行任务,可以用join等待线程执行完;而线程池无法等待某个线程执行结束
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0; i < MAXSIZE; i++){
                    count.getAndIncrement();
                }
            }
        });
        t1.start();

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0; i < MAXSIZE; i++){
                    count.getAndDecrement();
                }
            }
        });
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count);
//        AtomicInteger count = new AtomicInteger(0);
//        count.getAndIncrement(); 
//        count.incrementAndGet();
//        System.out.println(count); //结果:2

    }
}

运行结果为:0,表明使用AtomicInteger是线程安全的。

CAS底层实现原理

在Java层面来看,CAS是通过Unsafe类去实现的,Unsafe类调用c++实现的本地方法,通过调用操作系统的Atomic::cmpxchg(原子指令)实现CAS操作。
在这里插入图片描述
在这里插入图片描述

CAS优点:性能比较高

CAS缺点:存在ABA问题(Atomic类带来的ABA)

A:预期旧值
B:新值


ABA问题

1)执行转账操作,不小心多点击一次转账:
正常情况下,代码执行是正常的结果

public class TestDemo1 {

    private static AtomicReference money = new AtomicReference(100); //转账之前余额
    public static void main(String[] args) {

        //转账线程1  -100元
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                //传递旧值和新值
                boolean flag = money.compareAndSet(100,0);
                System.out.println("第一次转账:"+flag);
            }
        });
        t1.start();

        //转账线程2 -100元
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                boolean flag = money.compareAndSet(100,0);
                System.out.println("第二次转账:"+flag);
            }
        });
        t2.start();
    }
}

在这里插入图片描述

在这里插入图片描述
可以发现第一次转账成功为true,第二次转账失败返回false.

2)中途意外获得一次入账(ABA问题产生)

public class TestDemo2 {
    private static AtomicReference money = new AtomicReference(100); //转账只之前余额
    public static void main(String[] args) throws InterruptedException {

        //转账线程1  -100元
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                //传递旧值和新值
                boolean flag = money.compareAndSet(100,0);
                System.out.println("第一次转账:"+flag);
            }
        });
        t1.start();
        t1.join();

        //转入100元
        Thread t3 = new Thread(new Runnable() {
            @Override
            public void run() {
                //+100
                boolean flag = money.compareAndSet(0,100);
                System.out.println("转入100元"+flag);
            }
        });
        t3.start();
        t3.join();

        //转账线程2 -100元
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                boolean flag = money.compareAndSet(100,0);
                System.out.println("第二次转账:"+flag);
            }
        });
        t2.start();
    }
}

在这里插入图片描述
明显看到,第一次转账和第二次转账都为true,不是原本预期的结果;之所以第二次转账成功是因为原本内存的值已经被线程3修改。

解决ABA问题

统一解决方案:每次修改之后增加版本号

使用AtomicStampedReference类:

AtomicStampedReference atomicStampedReference = new AtomicStampedReference();

/**
 * 解决ABA问题
 */
public class TestDemo3 {
    private static AtomicStampedReference money = new AtomicStampedReference(
            100,1); //传递初始值和版本号
    public static void main(String[] args) {

        //转账线程1  -100元
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                //传递旧值和新值
                boolean flag = money.compareAndSet(
                        //四个参数分别是:预期旧值  新值  预期旧版本号  新版本号
                        100,0,1,2);  
                System.out.println("第一次转账:"+flag);
            }
        });
        t1.start();
        try {
            t1.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        //转入100元
        Thread t3 = new Thread(new Runnable() {
            @Override
            public void run() {
                //+100
                //转入是第二次操作 旧版本号 2 新版本号 3
                boolean flag = money.compareAndSet(0,100,
                        2,3);
                System.out.println(flag);
            }
        });
        t3.start();
        try {
            t3.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        //转账线程2 -100元
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                boolean flag = money.compareAndSet(100,0,
                        1,2);
                System.out.println("第二次转账:"+flag);
            }
        });
        t2.start();
    }
}

在这里插入图片描述
完美解决了上面存在的ABA问题!


面试题:AtomicReference和AtomicStampedReference的区别是什么呢?

AtomicReference会产生ABA问题,而AtomicStampedReference不会产生ABA问题。

知识点补充:我们把转账数额由100改成1000时,运行结果 false false false,这是因为Integer高速缓存的问题,具体我们看一下Integer源码中IntegerCache类

在这里插入图片描述
分析:Integerg高速缓存范围:-128 ~127,当我们设置1000时候出现false,实际上对比的是引用而不是数值;100存在于高速缓存范围内,因此获得对象都是同一个,而1000在范围之外,需要去new,因而获得是两个不同的对象 。

解决方案:调整Integer高速缓存的边界值,对当前程序进行应用程序参数设置
在这里插入图片描述

悲观锁

它认为程序通常情况下会出现并发冲突,所以在一开始就会进行加锁(比如synchronized就是悲观锁)。

共享锁与非共享锁

共享锁定义:一把锁可以被多个程序拥有,这就叫共享锁(比如读写锁中读锁)。
非共享锁定义:一把锁只能被一个线程拥有,这就叫非共享锁(比如synchronized是非共享锁)。

读写锁

读写锁定义:将一把锁分为两部分,一个是读数据的锁(读锁),另外一个是写数据的锁(写锁);读锁是可以被多个线程同时拥有的,而写锁在一个时间段内只能被一个线程拥有。

读写锁的优势:锁的粒度更细,性能更高。

读写锁的具体实现:ReentrantReadWriteLock类

读写锁代码演示:

public class TestDemo4 {

    public static void main(String[] args) {
        //创建一个读写锁
        ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();

        //分离出读锁
        ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();

        //分离出写锁
        ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();

        //声明线程池
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10,10,0, TimeUnit.SECONDS,
                new LinkedBlockingDeque<>(1000));

        //任务1.读锁
        threadPoolExecutor.execute(new Runnable() {
            @Override
            public void run() {
                //加锁
                readLock.lock();
                try{
                    //业务逻辑处理
                    System.out.println(Thread.currentThread().getName()+
                            "执行了读锁操作"+new Date());
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }finally{
                    readLock.unlock(); //释放锁
                }
            }
        });

        //任务2.读锁
        threadPoolExecutor.execute(new Runnable() {
            @Override
            public void run() {
                //加锁
                readLock.lock();
                try{
                    //业务逻辑处理
                    System.out.println(Thread.currentThread().getName()+
                            "执行了读锁操作"+new Date());
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }finally{
                    readLock.unlock(); //释放锁
                }
            }
        });

        //任务3.写锁
        threadPoolExecutor.execute(new Runnable() {
            @Override
            public void run() {
                //加锁
                writeLock.lock();
                try{
                    //业务逻辑处理
                    System.out.println(Thread.currentThread().getName()+
                            "执行了写锁操作"+new Date());
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }finally{
                    writeLock.unlock(); //释放锁
                }
            }
        });

        //任务4.写锁
        threadPoolExecutor.execute(new Runnable() {
            @Override
            public void run() {
                //加锁
                writeLock.lock();
                try{
                    //业务逻辑处理
                    System.out.println(Thread.currentThread().getName()+
                            "执行了写锁操作"+new Date());
                    try {
                        Thread.sleep(3000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }finally{
                    writeLock.unlock(); //释放锁
                }
            }
        });
    }
}

在这里插入图片描述
可以看出,读操作之间没有执行我们设定的时间间隔,说明读锁是共享的;写操作之间间隔了我们设定的时间,表明同一时刻写锁只能被一个线程占有。

注意事项:读写锁中读锁和写锁是互斥的,为了防止同时读写所产生的脏数据。

公平锁与非公平锁

公平锁的定义:锁的获取顺序与线程方法的先后顺序一致,这就叫公平锁。
非公平锁的定义:锁的获取顺序和线程方法的先后顺序无关,这就叫非公平锁。(java默认锁策略)

优点:公平锁执行是有序的,执行结果是可预期的;非公平锁性能比较高。

显示设置公平锁:

ReentrantLock reenrantLock = new ReentrantLock();

非公平锁:

//不写参数即默认false
ReentrantLock reenrantLock = new ReentrantLock(false); 

自旋锁

自旋锁:通过死循环一直尝试获取锁

while(true){
	if(获取锁的条件){
		return ;
    }
}

可重入锁

可重入锁定义:当一个线程获取到锁之后,该锁可以重复进入(比如synchronized与Lock)。

可重入锁代码演示:

public class TestDemo6 {
    //创建锁
    private static Object lock = new Object();

    public static void main(String[] args) {
        //第一次进入锁
        synchronized(lock){
            System.out.println("第一次拿锁");
            synchronized (lock){
                System.out.println("第二次进入锁");
            }
        }
    }
}

在这里插入图片描述
执行结果已经表明synchronized是可重入锁!

相关问题

1.请问你怎么理解乐观锁和悲观锁?实现原理清楚吗

答:你好,乐观锁是基于CAS机制去实现的,而CAS通过Atomic相关类去实现;CAS中含有三个组成部分V(内存值)、A(预期旧值)、B(新值),执行的时候使用A和V进行对别,如果相等(true)表明没有发生并发冲突,则可以直接修改数据,否则不能直接修改。
Java中CAS是通过调用Unsafe类中c++本地方法(compareAndSwapXXX,XXX可以是Object\\Integer\\Long)去实现的,本地方法通过调用操作系统Atomic::cmpxchg原子指令实现的。
悲观锁在程序一开始就会进行加锁,比如synchronized在Java中将锁的ID存放在对象头中。对象头中有一个偏向锁ID,线程会将自己的id放到偏向锁ID中,每次有线程去访问的时候,将当前线程id和偏向锁ID进行判断,如果相等表明该线程拥有了这把锁,执行相应代码;否则通过自旋去尝试获取锁。synchronized在Java层面是通过监视器锁实现的,在操作系统层面通过互斥锁mutex实现的。

2.铁子,你了解读写锁吗?

答:将锁的粒度分的更细,分为读锁和写锁;读锁和写锁是互斥的,为了防止脏读现象(修改时候也在读取);读锁可以被多个线程同时拥有因此是共享锁,而写锁在一个时间段内只能被一个线程拥有因此是非共享锁;读写锁在Java中可以通过ReentrantReadWriteLock去创建,可以将创建的对象通过ReentrantReadWriteLock.WriteLock与ReentrantReadWriteLock.ReadLock将读锁与写锁分离出来;

3.什么是自旋锁?为什么要使用自旋锁策略,缺点是什么?

答:
自旋锁的缺点:如果发生了死锁则会一直自旋(循环),做无用功,带来一定的性能开销(CPU资源)。
解决自旋方案:给自旋设置次数,也就是给明确的循环边界值;先将线程放在等待队列中,等有锁了去唤醒线程。

4.synchronized是可重入锁吗?

答:是可重入锁!!!

5.synchronized锁优化(锁消除)过程是什么

以上是关于深入Java多线程锁策略的主要内容,如果未能解决你的问题,请参考以下文章

深入Java多线程锁策略

Java多线程锁的优化策略

深入理解多线程—— Java虚拟机的锁优化技术

Java多线程常见面试题-第一节:锁策略CAS和Synchronized原理

Java多线程系列:深入详解Synchronized同步锁的底层实现

Java多线程系列:深入详解Synchronized同步锁的底层实现