Java中的【锁】事 - 极客大学架构师训练营 架构师 Albert 分享

说明

分享架构师: Albert

1. 为什么要使用锁?

首先,还是从问题出发,操作系统为什么要设计锁?锁用来解决什么问题?
这里就要先看看并发编程带来的问题;

1-1、原子性问题

加法问题

先来看以下代码,这段代码在单线程环境下,累加多少次都会和我们预想的一致,但是在多线程环境下,这段代码计算结果也许会和预期的不一样;
Java中的【锁】事 - 极客大学架构师训练营 架构师 Albert 分享
当有两个线程同时执行上面的代码时,很可能会出现下图中的情况,预期count的值等于2,很有可能出现为1的情况;可以看看下图
Java中的【锁】事 - 极客大学架构师训练营 架构师 Albert 分享
具体会产生上面的原因,因为count++【并不是一个原子操作】,对于count++来说,在指令级别是三个操作过程:

1、获取count的值;
2、对count的值+1;
3、最后将计算结果重新赋值给count;

单例模式问题

另外,除了上面说的【原子性】问题,还有一个就是【可见性】问题,之前我们的作业中,大家都写过关于【单例模式】的代码,

实现单例模式的方法叫【延迟初始化】,一般在首次获取这个实例时创建对象,参考下面这段代码:
Java中的【锁】事 - 极客大学架构师训练营 架构师 Albert 分享
在【单线程环境】下,上面的代码可以仅创建一个UnsafeLazyLoad实例,

但是在【多线程情况】下,假如多个线程同时执行上面的代码,则会出现一种叫【竞态条件】的现象:
基于某个失效的条件来执行某个计算;

当两个线程同时执行getInstance这段代码,很可能都发现singleton为null,这时两个线程会同时new 两个对象,最后返回的是不同的实例。

像【原子性】和【可见性】问题都属于【线程安全】的问题,Java为了保证线程安全,于是就引入【锁】的方式。

课上老师介绍了很多锁的概念,接下来就根据每个概念展开,看看java中是怎么实现各种锁的。

2. 使用锁会带来什么问题?

乐观锁和悲观锁

Java实现 悲观锁 的主要方式:synchronized 和 ReentrantLock

synchronized在代码中的使用,如代码所示:

A. 对于静态方法,锁的对象就是 方法所在的类;

B. 对于非静态方法,锁的对象就是 调用方法的实例;

C. 对于代码块,锁的对象就是 synchrobized后面括号中的对象;

Java中的【锁】事 - 极客大学架构师训练营 架构师 Albert 分享

synchronzied 底层实现,是 通过【monitorenter】和【monitorexit】两个指令,完成对 同步代码块的 线程安全,

但是 synchronzied 本身也有很多问题,java也针对这些问题进行优化,在后续和大家介绍;

另一个现实锁 ReentrantLock,一般用 lock加锁,类似 synchronzied的【monitorenter】
用 unlock 解锁,类似 ynchronzied的【monitorexit】,

还提供了带有等待时间的tryLock方法,在实现上通过一个随机长度的等待时间,这个【超过时间就释放锁】的机制,解决了锁带来 的【活锁】和【死锁】问题。

3. 各种锁在Java中的实现;

接下来我们看看 Java 是怎么实现 乐观锁 的

Java的 乐观锁,就是课上老师提到的【CAS原语】,

Java中【sun.misc.Unsafe】这个类中提供的方法,而【Unsafe】提供的【CAS方法】,底层实现是CPU指令cmpxchg;

相关方法如下,以compareAndSwapInt(Object var1, long var2, int var4, int var5)为例,包括四个参数,分别代表的含义是:

A . Object obj : 要进行CAS操作值所在的对象;
B. long var2: 对象中某个属性的内存地址;
C. int var4 :预期值;
D. int var5:新值;

Java的 乐观锁 就是这么实现的,但是 乐观锁 同样有自己的问题,那就是 【ABA】问题

如果一个变量V初次读取的时候是A值,赋值准备的时候检查期间,值曾经被改成B,后来又被改回A,CAS操作会误认为这个值从没有改变过,也是就是【A-B-A】这样的现象,所以这个漏洞称为CAS操作的【ABA问题】;

解决【ABA】的问题很简单,就是在更新时使用不是一个引用,而是两个:一个是期望值,另一个是版本号,用【值 + 版本号】的方式更新;

Java从1.5版本,通过引入【AtomicStampedReference】来解决ABA问题,具体实现参考compareAndSet方法,其中除了【期望的引用】和【当前引用】是否相等,还增加了【预期标志】与【当前标记】是否相等的判断,具体可以参考下面代码:
Java中的【锁】事 - 极客大学架构师训练营 架构师 Albert 分享

接下来 看看 Java 是怎么实现 【公平锁】和【非公平锁】

在Java中,synchronized是非公平锁,ReentrantLock默认是非公平锁,可以通过构造函数传入值true来实现公平锁
Java中的【锁】事 - 极客大学架构师训练营 架构师 Albert 分享
接下来,我们 再 看看 java中 【公平锁】和【非公平锁】的加锁实现.
这是【公平锁】的加锁方式,注意 图中 红框 标记的代码
Java中的【锁】事 - 极客大学架构师训练营 架构师 Albert 分享
【非公平锁】加锁 实现方式 如下:
Java中的【锁】事 - 极客大学架构师训练营 架构师 Albert 分享
通过源代码对比,我们可以明显的看出【公平锁】与【非公平锁】的区别就在于:

公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors(),

主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。
Java中的【锁】事 - 极客大学架构师训练营 架构师 Albert 分享
接下来,大家一起看看 【可重入锁】,在Java中是如何实现的,

首先 synchronized 除了是 【悲观锁】,本事也是【可重入锁】

关于可重入锁如何设计,《Java并发编程实战》这本书也给出了对应方法:

1、为每个锁关联一个【获取计数值】和一个【所有者线程】;
2、当计数值为0时,这个锁就被认为是没有被任何线程持有;
3、当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将【获取计数值】置为1;
4、如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减;
5、当计数值为0时,这个锁就被释放;

刚才介绍的ReentrantLock,除了是【悲观锁】以外,也是一个【可重入锁】,

它内部的Sync类继承AQS,通过维护一个【state】记录重入锁【获取计数值】,在AQS内部,【state】用volatile定义,这样保证了可见性。

我们看看 【ReentrantLock】【可重入】是怎么实现的,代码如下所示:
Java中的【锁】事 - 极客大学架构师训练营 架构师 Albert 分享
其实,Java中也有【非重入】锁的设计,这个实现就在我们常用的 线程池对象 ThreadPoolExecutor 的 Worker类
Java中的【锁】事 - 极客大学架构师训练营 架构师 Albert 分享
大家有兴趣可以讨论一下,为什么【线程池】中的 锁 不能重入。

4. 对锁进行优化设计思考

1、分段锁
就是将锁的数据分成多个段,每次只锁部分,这个是在JDK1.7之前ConcurrentHashMap实现的方法。
分段保证并发情况下,获取对象的性能

2、减小锁的范围
在程序代码中,我们通过减小锁的范围,来提升程序的性能,这样可以有效降低竞争发生的可能,减少【串行执行】的时间。
这里要提醒的是,锁的范围必须保证原子性,比如【多个变量更新维持一个不变性条件】的操作,或者我们上面说的count++,可以将 synchronized放入 方法里

3、减小锁的粒度
也是就是为多个独立的变量用多个锁进行管理,比如 统计 用户登录 和 用户下单 的数量,这两个数据互不影响,就可以用不同的锁区保护他们,
这里需要注意的是,一个共享变量只能被一个锁保护,而 一个锁 可以 保护多个共享变量。

Java本身提供多种安全并且性能较好的锁实现方案,我们除了站在实现的角度看,

更可以站在架构的角度,一起探讨Java的代码设计 的合理性,有没有更好的实现方法,包括锁、线程池等等。

并发操作中的【乐观锁】

另外,关于【乐观锁】,在并发操作 数据库中的数据时,也会出现

以MySQL为例,默认的隔离级别是可重复读,

假如有两个请求在各自的事务中,先查询同一条数据,同去更新查询出来的数据,

如果A事务更新成功并提交,后面B事务也更新同样的数据,那么A事务更新的数据就被覆盖掉了,举一个贴近现实的例子:

A和B两个账户同时给账户C转账,A和B转账是两个事务,A给C转账更新账户C的余额,接着B的转账也更新账户C的余额,B的事务就会把之前A的更新给覆盖。

对这种情况,我们也可以参考上面的方法,通过【值(一般是数据的主键) + 版本号】的方式,更新数据,

如果更新 返回 影响的行数 == 0,说明没有更新,

通常也要 考虑在【并发】场景下,对数据库中 数据 的【更新】操作