手记

如果能把锁附加在代码上,那该有多酷啊。

       在并发编程中,经常遇到多个线程访问同一个 共享资源 ,这时候作为开发者必须考虑如何维护数据一致性,在java中synchronized关键字被常用于维护数据一致性。synchronized机制是给共享资源上锁,只有拿到锁的线程才可以访问共享资源,这样就可以强制使得对共享资源的访问都是顺序的,因为对于共享资源属性访问是必要也是必须的。要深入了解锁机制,我们就得先了解Java中的并发体系:java.util.concurrent(J.U.C)。

一.原子操作

       从相对简单的 Atomic 入手(java.util.concurrent 是基于 Queue 的并发包,而 Queue,很多情况下使用到了 Atomic 操作,因此首先从这里开始)。很多情况下我们只是需要一个简单的、高效的、线程安全的递增递减方案。注意,这里有三个条件:

简单,意味着程序员尽可能少的操作底层或者实现起来要比较容易;

高效,意味着耗用资源要少,程序处理速度要快;

线程安全也非常重要,这个在多线程下能保证数据的正确性。这三个条件看起来比较简单,但是实现起来却难以令人满意。

老版本用纯Java实现,不可避免的采用了synchronized关键字。

public final synchronized void set(int newValue);

public final synchronized int getAndSet(int newValue);

public final synchronized int incrementAndGet(); 

同时在变量上使用了 volatile来保证 get()的时候不用加锁。尽管 synchronized 的代价还是很高的,但是在没有 JNI 的手段下 纯 Java 语言还是不能实现此操作的。JDK 5.0 里面了,在这里面重复使用了现代 CPU 的特性来降低锁的消耗。后本章的最后小结中会谈到这些原理和特性。在此之前先看看 API 的使用。 一切从 java.util.concurrent.atomic.AtomicInteger 开始。

AtomicIntegerArray/AtomicLongArray/AtomicReferenceArray 的 API 类似,选择有代表性的 AtomicIntegerArray 来描述这些问题。 int get(int i)   获取位置 i 的当前值。很显然,由于这个是数组操作,就有索引越界的问题(IndexOutOfBoundsException 异常)。

二.volatile 语义

在J.U.C中 存在大量的 volatile,但是却仍然没有理解 volatile 的语义。

volatile 相当于 synchronized 的弱实现,也就是说 volatile 实现了类似 synchronized 的语义,却又没有锁机制。它确保对 volatile 字段的更新以可预见的方式告知其他的线 程。 volatile 包含以下语义:

(1)Java 存储模型不会对 valatile 指令的操作进行重排序:这个保证对 volatile 变量的操作时按照指令的出现顺序执行的。

(2)volatile 变量不会被缓存在寄存器中(只有拥有线程可见)或者其他对 CPU 不可见的地方,每次总是从主存中读取 volatile 变量的结果。也就是说对于 volatile 变量的修 改,其它线程总是可见的,并且不是使用自己线程栈内部的变量。也就是在 happens-before 法则中,对一个 valatile 变量的写操作后,任何读操作理解可见此写操作的结果。

       尽管 volatile 变量的特性不错,但是 volatile 并不能保证线程安全的,也就是说 volatile 字段的操作不是原子性的,volatile 变量只能保证可见性(一个线程修改后其它线程能 够理解看到此变化后的结果),要想保证原子性,目前为止只能加锁!

应用 volatile 变量的三个原则

(1)写入变量不依赖此变量的值,或者只有一个线程修改此变量

(2)变量的状态不需要与其它变量共同参与不变约束

(3)访问变量不需要加锁 

三.Happens-before 法则

        Java 存储模型有一个 happens-before 原则,就是如果动作 B 要看到动作 A 的执行结果(无论 A/B 是否在同一个线程里面执行),那么 A/B 就需要满足 happens-before 关 系。 在介绍 happens-before 法则之前介绍一个概念:JMM 动作(Java Memeory Model Action),Java 存储模型动作。一个动作(Action)包括:变量的读写、监视器加锁和 释放锁、线程的 start()和 join()。后面还会提到锁的。

-------------紧接着,我们就来看看锁的种类及其用法-------------

一.悲观锁乐观锁

1.悲观锁

       对世界充满不信任,认为一定会发生冲突,因此在使用资源前先将其锁住,具有强烈的独占和排他特性。悲观锁认为一定会有人和它同时访问目标资源,因此必须先将其锁定,常见的synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

每次有线程访问这个资源(方法)时,count都是1,也就是说只有一个线程在访问它,这个线程在访问前先锁定了资源,导致其他线程只能等待。

二.乐观锁

       所谓乐观锁就是,每 次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。 乐观锁用到的机制就是 CAS,Compare and Swap。 CAS 有 3 个操作数,内存值 V,旧的预期值 A,要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则什么都不做。 

       拿出 AtomicInteger 来研究在没有锁的情况下是如何做到数据正确性的。 private volatile int value; 首先毫无以为,在没有锁的机制下可能需要借助 volatile 原语,保证线程间的数据是可见的(共享的)。 这样才获取变量的值的时候才能直接读取。 

       CAS 算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。 比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且 two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但是不代表这个过程就是没有问题的。如果链表的头在变化了两次后恢复了原值,但 是不代表链表就没有变化。因此前面提到的原子操作 AtomicStampedReference/AtomicMarkableReference 就很有用了。这允许一对变化的元素进行原子操作。ABA问题

三.AQS

       AbstractQueuedSynchronizer,简称 AQS,抽象队列式的同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock,下图是一个简单的模式图:

       AQS有一个 volatile int state的变量(代表共享资源,volatile的作用主要在于当前线程改变valotile修饰的变量后,该变量对于其他线程具有可见性)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列,本质是一个双向链表,每个节点保存一个阻塞的线程,多线程竞争有限的资源,对线程阻塞排列)。

       总得来说,state初始化为0,表示未锁定状态。线程A如果能拿到锁,0→1,设定当前线程为独享锁;否则,进入等待队列。

基本的思想是表现为一个同步器,支持下面两个操作:

获取锁:首先判断当前状态是否允许获取锁,如果是就获取锁,否则就阻塞操作或者获取失败,也就是说如果是独占锁就可能阻塞,如果是共享锁就可能失败。另外如果是阻塞 线程,那么线程就需要进入阻塞队列。当状态位允许获取锁时就修改状态,并且如果进了队列就从队列中移除。 

释放锁:这个过程就是修改状态位,如果有线程因为状态位阻塞的话就唤醒队列中的一个或者更多线程。 

要支持上面两个操作就必须有下面的条件:

原子性操作同步器的状态位   -->cas

阻塞和唤醒线程

一个有序的队列   --CLH

四.公平锁和非公平锁 

       如果获取一个锁是按照请求的顺序得到的,那么就是公平锁,否则就是非公平锁。在没有深入了解内部机制及实现之前,先了解下为什么会存在公平锁和非公平锁。公平锁保证一个阻塞的线程最终能够获得锁,因为是有序的,所以总是可以按照请求的顺序获得锁。不公平锁意味着后请求锁的线程可能在其前面排列的休眠线程恢复前拿到锁,这样就有可能提高并发的性能。这是因为通常情况下挂起的线程重新开始与它真正开始运行,二 者之间会产生严重的延时。因此非公平锁就可以利用这段时间完成操作。这是非公平锁在某些时候比公平锁性能要好的原因之一。

       两者最大的区别主要在于,公平锁的公平性主要体现在获取锁的线程是否为等待队列里的头节点,所以每次要判断等待队列中的当前线程是否有前驱节点:若有则说明有比当前线程更早的请求,根据公平性,当前线程请求资源失败;若prev=null这个前提成立,才会进行后续的判断。

五.独占锁和共享锁

独占锁:也叫排他锁,互斥锁,一次仅有一个线程可以占有临界资源。

共享锁:允许多个线程同时访问临界资源。这种情况多见于读锁ReadLock,即允许多个线程读取数据,有助于性能的提升。不过需要注意的是,读锁共享的目的是为了在多线程高频率访问下提升效率,如果线程访问量不大,维护读锁的成本会过高,有得不偿失的感觉。

六.内置锁

内置锁也称为隐式锁,Java中具有通过synchronized实现的内置锁,synchronized维护多线程同步非常方便,无需手动获取和释放锁,只需要将需要同步的部分放入synchronized代码块即可,执行完代码块,即释放锁。

特点:

synchronized是一种互斥锁,一次只允许一个线程访问修饰的代码;

原子性:synchronized修饰的代码块具有原子性,是一次执行的。

可见性:synchronized修饰的代码执行完后,结果对其他线程可见。

七.显式锁

Lock显式锁是一个接口,Lock方式来获取锁支持中断、超时不获取、是非阻塞的。它提高了语义化,哪里加锁,哪里解锁都得写出来,Lock显式锁可以给我们带来很好的灵活性,但同时我们必须手动释放锁。它支持Condition条件对象,允许多个读线程同时访问共享资源;

比较有代表性的显式锁是ReentrantLock,以此为例,再来详细说一下。

可重入性

ReentrantLock中,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其他线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证state是能回到零态的。

支持公平锁 ReentrantLock(true)

支持非公平锁ReentrantLock()/ReentrantLock(false)


公平锁的简要过程:

若state=0,证明当前锁没有被任何线程获取。然后就判断当前线程是否为等待队列里的时间最久的节点,若不是,证明还有比当前线程等待更久的线程,返回false;若是,证明当前线程是AQS中等待获取锁的第一个节点,基于CAS操作,尝试获取锁(0→1),若成功,则设置当前线程为独占锁,state=1。

若state!=0,证明锁被某个线程持有,则判断持有锁的线程是否为当前线程,若是,尝试再次获取锁(ReentrantLock重入性的体现),如果获取锁的次数没有超过上限,则更新state为当前线程获取锁的最终次数,结果返回true;否则,若当前线程获取锁的次数超过上限,或者当前线程不是正在持有锁的线程,则返回false。

基于CAS操作,若成功,则将当前设置为独占锁的线程;若失败,再次判断state的状态,如果是0,再次尝试CAS操作0→1,若成功,则设置当前线程为独占锁的线程;若失败或state!=0,查看当前线程是否为独占锁的线程(是:state+1;不是:将该线程封装在节点里加入等待队列)。

非公平锁的简要过程:

判断表示锁状态的state是否为0:若state=0,证明锁在当前未被任何线程持有,当前线程可以基于CAS操作尝试获取非公平锁(0→1),若成功,则把当前线程设置为持有锁的线程,若成功,state=1,返回true;若失败,证明有其他线程先获得了锁,返回false。若state!=0,则证明锁正在被某个线程持有,所以要判断当前线程是否为持有锁的线程,若是,尝试再次获取锁,如果当前线程拥有锁的总次数未超过上限,则表示当前线程获取锁成功,state的值更新为当前线程持有锁的总次数,返回true。否则,返回false。

八.Condition

条件变量很大一个程度上是为了解决 Object.wait/notify/notifyAll 难以使用的问题。

条件(也称为条件队列 或条件变量)为线程提供了一个含义,以便在某个状态条件现在可能为 true 的另一个线程通知它之前,一直挂起该线程(即让其“等待”)。因为访问此 共享状态信息发生在不同的线程中,所以它必须受保护,因此要将某种形式的锁与该条件相关联。等待提供一个条件的主要属性是:以原子方式 释放相关的锁,并挂起当前线程,就 像 Object.wait 做的那样。

九.闭锁

闭锁(Latch):代表是CountDownLatch一种同步方法,可以延迟线程的进度直到线程到达某个终点状态。通俗的讲就是,一个闭锁相当于一扇大门,在大门打开之前所有线程都被阻断,一旦大门打 开所有线程都将通过,但是一旦大门打开,所有线程都通过了,那么这个闭锁的状态就失效了,门的状态也就不能变了,只能是打开状态。也就是说闭锁的状态是一次性的,它确保 在闭锁打开之前所有特定的活动都需要在闭锁打开之后才能完成。

Semaphore:,Semaphore 是一个计数器,在计数器不为 0 的时候对线程就放行,一旦达到 0,那么所有请求资源的新线程都会被阻塞,包括增加请求到许可的线程,也就是说 Semaphore 不是可重入的。每一次请求一个许可都会导致计数器减少 1,同样每次释放一个许可都会导致计数器增加 1,一旦达到了 0,新的许可请求线程将被挂起。

十.分布式锁

为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行互斥控制。在单机环境中,Java中提供了很多并发处理相关的API。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!

分布式锁应该具备哪些条件:

1.在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;

2.高可用的获取锁与释放锁;

3.高性能的获取锁与释放锁;

4.具备可重入特性;

5.具备锁失效机制,防止死锁;

6.具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

分布式锁实现的三种方式:

  1. 基于数据库实现分布式锁

    缺点

    1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。

    2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。

    3、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。

    4、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

    解决方案

    1、数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。

    2、没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。

    3、非阻塞的?搞一个while循环,直到insert成功再返回成功。

    4、非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

2.基于缓存(Redis等)实现分布式锁

        使用锁命令 setnx

        缺点

        在这种场景(主从结构)中存在明显的竞态:

        客户端A从master获取到锁,

        在master将锁同步到slave之前,master宕掉了。

        slave节点被晋级为master节点,

        客户端B取得了同一个资源被客户端A已经获取到的另外一个锁。安全失效!

3.基于Zookeeper实现分布式锁


        实现原理

        使用zookeeper创建临时序列节点来实现分布式锁,适用于顺序执行的程序,大体思路就是创建临时序列节点,找出最小的序列节点,获取分布式锁,程序执行完成之后此序列节点消失,通过watch来监控节点的变化,从剩下的节点的找到最小的序列节点,获取分布式锁,执行相应处理,依次类推。

        实现步骤

        多个Jvm同时在Zookeeper上创建同一个相同的节点( /Lock)

        zk节点唯一的! 不能重复!节点类型为临时节点, jvm1创建成功时候,jvm2和jvm3创建节点时候会报错,该节点已经存在。这时候 jvm2和jvm3进行等待。

        jvm1的程序现在执行完毕,执行释放锁。关闭当前会话。临时节点不复存在了并且事件通知Watcher,jvm2和jvm3继续创建。

        ps:zk强制关闭时候,通知会有延迟。但是close()方法关闭时候,延迟小

        如果程序一直不处理完,可能导致死锁(其他的一直等待)。设置有效期~ 直接close()掉 其实连接也是有有效期设置的。




3人推荐
随时随地看视频
慕课网APP