锁,CAS,Synchronized 原理

作者:~小明学编程 

文章专栏:JavaEE

格言:热爱编程的,终将被编程所厚爱。
在这里插入图片描述

目录

常见的锁

悲观锁与乐观锁

悲观锁

乐观锁

读写锁

重量级锁 vs 轻量级锁

挂起等待锁和自旋锁

公平锁和非公平锁

可重入锁与不可重入锁

CAS

什么是CSA?

CAS是如何实现的

CAS的应用

实现原子类

实现自旋锁的

CAS中的ABA问题

Synchronized 原理

特点

加锁的过程

其它优化操作

锁消除

锁粗化

Callable 接口

JUC(java.util.concurrent) 的常见类

ReentrantLock

原子类

线程池

ExecutorService 和 Executors

ThreadPoolExecutor

信号量 Semaphore

CountDownLatch

线程安全的集合类

多线程环境使用 ArrayList

多线程环境使用队列

多线程环境使用哈希表

Hashtable

ConcurrentHashMap


常见的锁

悲观锁与乐观锁

悲观锁

顾名思义我们的悲观锁就比较的悲观,在我们每次去拿数据的时候就会觉得数据会被人更改,觉得很多人会一起来抢这个数据,所以为了保证安全我们每次在拿数据的时候都会上锁一保证数据的安全。

乐观锁

乐观锁相对就比较的乐观,没有那么多的顾虑,我们在拿数据的时候不会觉得会有很多竞争或者数据被更改,只有在我们提交数据的时候才会进行校验,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

那么我们怎么检测出来我们的数据发生了冲突的呢,这就引入我们乐观锁的一个重要的功能那就是用版本号来管控。

就是我们内存中的数据会有一个版本号,每一个修改也会有一个版本,当我们修改完毕之后我们的版本号会+1并且会把数据写到内存里面内存里面数据的版本号也会+1,此时如果有其它的线程拿着旧的数据进行更改的话,那么它的版本号肯定会小于等于内存中数据的版本号的,此时就会产生错误,此次操作失败。

Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略
 

读写锁

当我们进行多线程操作的时候我们会发现,当我们只是进行读操作的时候即使多线程也不会有线程安全问题,我们的线程安全问题多半事发生在写操作的时候,所以在我们只有读的时候再加上锁未免有点浪费资源了。

读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行
加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进
行加锁解锁.


读加锁和读加锁之间, 不互斥.
写加锁和写加锁之间, 互斥.
读加锁和写加锁之间, 互斥
 

我们的Synchronized就不是读写锁。

重量级锁 vs 轻量级锁

重量级锁简单的来说就是做的事情更多开销更大,轻量级锁简单来说就是做的事情更加的少开销相对也小了很多。

重量级锁: 加锁机制重度依赖了 OS 提供了 mutex接口,操作系统的锁会在内核中做很多的事情,开销很大。
大量的内核态用户态切换。
很容易引发线程的调度。

轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex。
少量的内核态用户态切换。
不太容易引发线程调度。

我们的synchronized锁在开始的时候是一个轻量级的锁,当锁的冲突比较严重的时候就变成轻量级的锁了。

挂起等待锁和自旋锁

挂起等待锁往往就是通过内核来实现的比较重。

自旋锁往往是通过用户态来实现的比较的轻。

一般情况下我们的线程在抢占锁失败的时候会进入等待,但是这个等待时间一般不会太长,如果是我们的挂起等待锁的话我们就会放弃cpu将会等待很久,但是自旋锁的话就会一直去申请这个锁,一旦这个锁被释放了,就会立马拿到。

自旋锁是一种典型的 轻量级锁 的实现方式.
优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的)

synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的。

公平锁和非公平锁

公平锁就是很公平,当我们的多个线程来争夺统一个锁的时候,我们的公平锁就遵循先来后到的原则,也就是谁先来当这个锁被释放的时候谁就先获取这个锁。

而我们的非公平锁就是不公平的,多个线程等待同一把锁,当这个锁被释放的时候会随机的分给一个线程,没有公平可言。

操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序。

公平锁和非公平锁没有好坏之分, 关键还是看适用场景。

synchronized 是非公平锁
 

可重入锁与不可重入锁

可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁,而不可重入锁则不允许同一个线程重复的获取同一把锁。

不可重入锁容易造成自己把自己锁死的情况,也就是死锁的情况。

Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
而 Linux 系统提供的 mutex 是不可重入锁。

Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
 

CAS

什么是CSA?

CAS: 全称Compare and swap,也就是比较和交换的意思,是一种操作。

具体的我们可以将其拆分为一下的操作:

1.我们首先将预期的值和我们内存中拿到的值进行一个对比。

2.如果我们拿到的内存的值和预期的值一样那么就将内存中的值和我们想要更改的值交换,也就是向内存中写入我们要新的值。

3.返回我们的操作是否成功。

cas的伪代码

boolean CAS(address, expectValue, swapValue) {
        if (&address == expectedValue) {
            &address = swapValue;
            return true;
        }
        return false;
    }

但是不一样的是我们cas的操作是具有原子性的,比较和交换必须是同时一起都执行要么就都都不执行,返回false,当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。

至于为什么是原子性的那是因为有我们的硬件的支持,我们的硬件中提前编号了这样的一条cas指令。

这样的好处是实现了原子性保证了我们线程的安全,我们在硬件的层面上就保证了了线程的安全,从另一条思路上解决线程安全的问题。

CAS是如何实现的

针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:
java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。
简而言之,是因为硬件予以了支持,软件层面才能做到

CAS的应用

实现原子类

    public static void main(String[] args) throws InterruptedException {
        AtomicInteger atomicInteger = new AtomicInteger(0);
        Thread thread1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                atomicInteger.incrementAndGet();
            }
        });
        Thread thread2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                atomicInteger.incrementAndGet();
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(atomicInteger.get());//100000
    }

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作。

伪代码的实现

    class AtomicInteger {
        private int value;
        public int getAndIncrement() {
            int oldValue = value;
            while ( CAS(value, oldValue, oldValue+1) != true) {
                oldValue = value;
            }
            return oldValue;
        }
    }

实现自旋锁的

    public class SpinLock {
        private Thread owner = null;
        public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
            while(!CAS(this.owner, null, Thread.currentThread())){
            }
        }
        public void unlock (){
            this.owner = null;
        }
    }

可以看到cas也帮助实现了自旋锁,如果当前的线程被别的线程锁持有那么就循环等待。

CAS中的ABA问题

什么是ABA问题呢?

ABA问题就是我们的比较环节发生了问题,我们cas在进行比较的时候会判断内存中的值是不是和旧的值一样,如果一样的话我们才做交换,那么我们不能判断我们内存中的值是不是已经被更改过了。

假如我们有100元,我们现在要进行-50元的操作,而且两个线程抢占执行,第一个线程判断了内存中就是100元,然后做叫做把我们处理完后的50与100交换,此时我们还剩50元,这个时候刚好第三个线程给打了50进去我们的钱就变成100了,然后我们的线程2进行比对的时候发现内存中是100没问题就进行-50的操作,这一套下来减了我们100元,主要就是因为后面的100并不是前面的100才导致出现了问题。

ABA问题的核心就是我们比较的那个值是不是没有被动过的原值,还是已经被操作过了但是刚好和我们期待的值一样了。

解决方法

我们可以采用乐观锁的方式去引入一个版本号来解决问题,每次更改数据的时候还要比较版本号就行了,如果当前的版本低于数据的版本号就直接返回错误,此次修改失败。

CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候:
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
 

Synchronized 原理

Synchronized 作为我们Java中常用的锁下面我们就来详细的了解了解它。

特点

前面我们介绍了很多的锁,下面我们就说说我们的Synchronized 属于哪一种(jdk1.8)。

1.在刚开始的时候是乐观锁,但是当竞争比较激烈的时候就变成了悲观锁。

2.刚开始的时候是轻量级锁,当等待时间比较长的时候就变成了重量级锁。

3.在实现轻量级锁的时候大概率会用到自旋锁。

4.是不公平的锁。

5.是可重写锁。

6.不是读写锁。

加锁的过程

我们的JVM把Synchronized分成了几个等级在不同的时候会是不同的锁,这几个过程分别是

无锁->偏向锁->轻量级锁->重量级锁

偏向锁 

偏向锁不是真的加了锁,而是在我们首个线程运行到此处的时候我们的偏向锁会给当前的对象头做一个标记,此时没有真正的加锁,当然也就避免了资源的消耗,因此此时我们就是一个单线程,既然是单线程又何必加锁呢,但是当我们后面有别的线程来的时候这个时候我们对象里面的锁标记会判断发现不是刚刚的线程,这个时候再不加锁的话就会出现线程的问题,所以这个时候就会加上刚刚标记里面的锁,因为之前标记好了所以加锁的速度很快,然后此时就变成了轻量级锁。

偏向锁的意义在于节省资源,在单线程的情况下没必要做无畏的资源浪费。

轻量级锁

接着我们就进入到了轻量级锁的阶段,此时我们认为当前的锁竞争还不是很激烈自适应为自旋锁,此时的轻量级线程是通过cas来实现的,竞争中的线程会一直的申请这个锁(因为线程不多竞争不激烈)。

自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.
也就是所谓的 "自适应"。

重量级锁

当我们的线程数量进一步的增多,竞争更加的激烈此时就会变成重量级锁,此处的重量级锁就是指用到内核提供的 mutex 。

此时我们执行加锁操作,进入操作系统的内核,然后去判定当前的锁是否被占用,如果没有被占用的话那就加锁然后返回用户态,如果当前的锁已经被占用的话,那就加锁失败此时的线程进入锁的等待队列,等待操作系统的唤醒,经过一系列的操作该锁被其它线程释放然后唤醒等待的线程,让其重新尝试获取该锁。

重量级锁的意义在于解决高竞争的情况但是相应的也会付出很多的资源,所以直到最后我们才开启重量级锁。

其它优化操作

锁消除

所谓的锁消除就是把锁给消除,因为用不着锁,什么时候用不着锁呢,当然是单线程的时候了,比如我们在进行一个单线程的操作,根本不涉及线程安全的问题此时用锁就是在浪费资源。

此时我们的编译器+jvm会帮我们把这个锁给优化消除掉。

锁粗化

所谓的锁的粗话和细化之间的区分就在于我们加锁的密度,比如一段十行的方法我们用一个锁就能解决问题,但是你偏偏在方法里面加了三四个锁,当然如果是特别需要我们执行完这行代码希望赶紧的释放该锁然后让其它线程来抢占执行也是有可能的,但是大多数情况下是不需要的,这就造成了资源大大的浪费,此时我们jvm又发功了让我们用一个锁将这一块代码全部都给包裹上。

Callable 接口

Callable 是一个 interface . 相当于把线程封装了一个 "返回值". 方便程序猿借助多线程的方式计算结果。

在前面我们使用runnable的时候也是描述一个任务但是该任务没有返回值我们在想要得到一个任务结束时候的返回值会很麻烦。

    static class Result {
        public int sum = 0;
        public Object lock = new Object();
    }
    public static void main(String[] args) throws InterruptedException {
        Result result = new Result();
        Thread t = new Thread() {
            @Override
            public void run() {
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                synchronized (result.lock) {
                    result.sum = sum;
                    result.lock.notify();
                }
            }
        };
        t.start();
        synchronized (result.lock) {
            while (result.sum == 0) {
                result.lock.wait();
            }
            System.out.println(result.sum);
        }
    }

首先我们要把最终想要的值放在一个类中当我们执行完毕的时候再去更改这个值,这个过程中我们还要对main()线程的代码进行等待对对象进行上锁让其不能直接执行出来,直到我们的任务执行完毕。

    public static void main(String[] args) {
        //为了方便的实现我们的的任务返回值
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 0; i <= 100; i++) {
                    sum += i;
                }
                return sum;
            }

        };
        //将callable包装起来便于后面拿到结果
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread thread = new Thread(futureTask);
        thread.start();
        try {
            System.out.println(futureTask.get());
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }

1.我们的Callable只是一个接口描述了一个任务罢了特别的这个任务有返回值。

2.将该任务放在FutureTask的实例里面存放我们的任务。

3.将FutureTask的实例放入线程里面去执行。

4.在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结
果。

JUC(java.util.concurrent) 的常见类

ReentrantLock

ReentrantLock也是可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全的。

用法:

lock(): 加锁, 如果获取不到锁就死等.
trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
unlock(): 解锁.

在reenttantLock中我们可以灵活的控制解锁的时间,在处理某些复杂的问题的时候具有优势,其中更是提供了trylock()的方法来控制加锁的时间以免造成死等的情况。

ReentrantLock 和 synchronized 的区别:

1.synchronized是一个关键字是jvm的内部实现的,而reetrantLock是一个类是在jvm外实现的。

2.synchronized在使用的时候是不需要释放锁的,而reetrantLock在使用的时候是需要手动太释放锁的,但是同时也带来遗忘的风险。

3.synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就
放弃。

4.synchronized是非公平锁,而ReentrantLock默认是公平锁但是也可以通过构造方法对其传入一个false值也可以实现非公平锁。

5.更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一
个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指
定的线程。

锁的选择:

锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
如果需要使用公平锁, 使用 ReentrantLock.


原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference
 

以 AtomicInteger 举例,常见方法有

addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;

线程池

ExecutorService 和 Executors


ExecutorService 表示一个线程池实例.
Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
ExecutorService 的 submit 方法能够向线程池中提交若干个任务.
 

ExecutorService pool = Executors.newFixedThreadPool(10);
    pool.submit(new Runnable() {
        @Override
        public void run() {
        System.out.println("hello");
    }
});

Executors 创建线程池的几种方式


newFixedThreadPool: 创建固定线程数的线程池
newCachedThreadPool: 创建线程数目动态增长的线程池.
newSingleThreadExecutor: 创建只包含单个线程的线程池.
newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.


Executors 本质上是 ThreadPoolExecutor 类的封装
 

ThreadPoolExecutor

ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定。

理解 ThreadPoolExecutor 构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
keepAliveTime: 临时工允许的空闲时间.
unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
workQueue: 传递任务的阻塞队列
threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
AbortPolicy(): 超过负荷, 直接抛出异常.
CallerRunsPolicy(): 调用者负责处理
DiscardOldestPolicy(): 丢弃队列中最老的任务.
DiscardPolicy(): 丢弃新来的任务

信号量 Semaphore

信号量就是用来记录我们的可用资源的数量,本质上就是一个计数器。

Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用。

其中P操作也就是acquire()也就是申请资源,可以申请一个也可以申请多个资源,申请完之后我们的总的资源数量就会相应的减少当资源全部被申请完了之后还有别的线程想要申请的话就会陷入阻塞的状态,直到有资源被释放也就是我们的V操作realease()操作。

 可以看到刚开始的时候只有4个获取到了资源剩下的都在等待,继续申请直到有资源释放了。

CountDownLatch

CountDownLatch的作用是等待n个线程全部结束,然后就一直阻塞。

前面我们说到了join()是阻塞当前所在的线程然后等目标线程走完再停止阻塞,但是当我们有多个线程的时候我们就不好说等待哪一个线程走完,而是我们要的是全部线程走完然后才进行main()线程的下一步,这个时候用countDownLatch就比较的合适了。

    public static void main1(String[] args) throws InterruptedException {
        CountDownLatch count = new CountDownLatch(10);//初始化10个任务
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(()->{
                System.out.println(Thread.currentThread().getName()+"已经执行完毕");
                count.countDown();
            });
            thread.start();
        }
        count.await();
        System.out.println("全部线程执行完毕");
    }

如上述的代码我们描述了是个任务,下面开了10个线程,每个线程结束了coutDown一个告诉对象我们完成了一个任务,当所有的对象都完成了任务await()停止阻塞打印全部线程执行完毕。

线程安全的集合类

多线程环境使用 ArrayList

1.自己使用同步机制 (synchronized 或者 ReentrantLock),我们可以通过自己加锁的方式来实现线程的安全。

2.Collections.synchronizedList(new ArrayList),这里我们synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.synchronizedList 的关键操作上都带有 synchronized这样的话锁加的太多会导致锁竞争非常的激烈。

3.使用 CopyOnWriteArrayList:

CopyOnWrite容器即写时复制的容器

当我们在进行读操作的时候没有什么影响直接读的完了,但是当我们在进行写操作的时候我们不对原数据直接进行更改而是先拷贝一份数据对拷贝的数据进行更改,最后我们再将拷贝的数据指定为最新的数据。

优点:

1.这样可以避免锁竞争大大的提高了效率。

缺点:

1.我们频繁的拷贝数据会浪费一些资源。

2.我们在读的时候有可能读不到最新的数据。

多线程环境使用队列

1) ArrayBlockingQueue
基于数组实现的阻塞队列
2) LinkedBlockingQueue
基于链表实现的阻塞队列
3) PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
4) TransferQueue
最多只包含一个元素的阻塞队列

多线程环境使用哈希表

我们知道我们的HashMap是线程不安全的,在多线程的情况下有两种保证线程安全的哈希表

Hashtable

Hashtable只是简单的在关键的方法上加上锁,这就相当于对我们当前的对象进行加锁这样的方式效率非常的低,会造成一些问题。

1.如果多线程访问同一个 Hashtable 就会直接造成锁冲突。

2.size 属性也是通过 synchronized 来控制同步, 也是比较慢的。

3.当需要扩容的时候,将会由整条线程来承担扩容任务效率非常低。

ConcurrentHashMap

相比于 Hashtable 做出了一系列的改进和优化. 以 Java1.8 为例

1.首先读操作没有加锁避免不必要的资源浪费但是加了volatile关键字,保证我们从内存中读到数据确保结果的准确性。

2.对写操作的加锁方式进行更改,用的还是synchronized()但是由原来的对对整个对象进行加锁变成了,对每个哈希桶进行加锁,也就是对底层数组的链表头节点进行加锁,降低了锁之间的冲突。

3.充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况。

4.优化了扩容方式:
1)发现需要扩容的时候我们会创建一个新的数组作为哈希表,同时搬运几个数组过去。

2)在扩容的期间新数组和旧的数组同时存在。

3)后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小
部分元素,插入的时候只向新的数组插入,知直到搬运完为止。

4)查找的时候新的和旧的一起查找。

猜你喜欢

转载自blog.csdn.net/m0_56911284/article/details/128378717