java特种兵读书笔记(5-3)——并发之原子性与锁

来源:互联网 发布:自动卸载软件脚本 编辑:程序博客网 时间:2024/05/20 06:29

synchronized


synchronized是针对一个临界区的,所谓临界区就是指访问这个地方最多只能有一个线程在里面(比如一支笔可以被多人共用,但是在一段时间内只能一个人使用)。synchronized通过在对象上加锁后进入临界区来达到临界区串行化访问的目的。

对象本身的作用域将决定锁的粒度。

1.普通方法前加synchronized

等价于在方法体前后包装一个synchronized(this){...},即给当前类所在的对象(每个实例化的对象)上加了锁标记。

与它互斥的几种情况:

①该类的所有非静态方法中发生synchronized(this)

②在该类所有非静态方法前面加上synchronized

③在其他类中得到该对象的引用,并对该对象进行synchronized操作。

2.静态方法前面加synchronized

相当于锁住了当前类的class对象。比如这个类是A,那么锁住的是A.class。互斥:

①代码中任意一个地方发生synchronized(A.class)

②在该类的所有静态方法前面增加synchronized关键字

锁住了类不等于锁住了对象,类本身也是一种对象,它与类的实例完全是两个不同的对象,在加锁时不相互依赖。

我们常把它叫做代码块加锁,其实叫做对象加锁更确切。这个对象在执行这段synchronized代码,该对象加上了“锁标记”,那么该锁住的对象在另一个线程中无法执行另一段带synchronized代码,因为该对象被锁。

因此java中的synchronized就真正能做到临界区域,区域的范畴不取决于方法,而是取决于对象的范畴。临界区内多个线程的操作绝对是串行的。

synchronized的开销很大,如果无法控制好锁的粒度,就会造成频繁的锁征用,进入悲观锁状态。

乐观锁


乐观是我们基于历史经验,在一个自己很熟悉并且常去的地方,认为大多数情况下没问题。悲观是我们没有历史经验,到一个新的地方,认为什么都是有问题的。

如果是悲观锁,意味着必须发生锁操作,不论多个线程之间会不会发生冲突,都必须要加锁。

悲观锁会产生非常多的指令开销来用以同步,甚至会由于锁的粒度使用不当,导致不应该被锁住的代码被锁住了。

当访问量很大的时候,在设计的很好的系统中也会出现一些冲突,尽管概率很低。这时需要乐观锁——CAS机制。

CAS机制是先获取Old值,然后计算出New值,基于可见性的变量检查是否修改,若没有修改,则回写到主存表示成功。

这个步骤大概几条指令就可以完成,如果多个线程发生征用,那么只有一个线程能成功。与悲观锁相比,绝大部分情况下开销没有那么大。外表上的特征就是,通常不会将线程挂起。

并发与锁举例


某种促销活动正在进行,一辆车一块钱,这会引来大批观众。如果按照先到先得的原则,大家都会去抢,这会导致大量的系统并发写征用。如果改变策略,比如结果已经内定,报名后随机统一抽取,几秒内的请求随机抽奖,这样并发的压力就小很多。

第一个抢到的人去付款,这没问题,如果是悲观锁,后面的人不知道这辆车已经卖出去了,从而继续排队,后面的线程逐一尝试。因为等待的人很多,就需要系统保持大量的会话以及它们的上下文,就会有越来越多的资源被占用而无法释放,导致系统变慢,系统变慢导致单个业务处理速度变慢,会堵住更多的人,恶性循环,达到临界点后出现连锁反应导致悲剧发生。

我们希望第一个人抢到后,在办手续的时候,后面的人已经知道车被卖了,不用再等了。在程序上可以直接告知活动结束,或者通过某种方式让大家在大屏幕上看到,所有人就一起离开了,在程序中等价于所有的线程都释放了上下文资源。

atomic


synchronized用法粒度始终是在代码级别,使用不当就会成为悲观锁。许多时候希望对原子变量进行叠加,或者通过某原子变量是否修改成功来判断线程是否得到锁。

atomic为我们提供了一系列java原子变量的操作方法,大部分情况我们不用基于CAS机制做二次封装,除非我们想自己实现锁的控制粒度,比如希望自旋的次数可以收到某种算法的控制。

基本变量操作对应的AtomicInteger/Boolean/Long。

引用变量操作对应的AtomicMarkable/StampedReference。

数组操作对应的AtomicInteger/Long/ReferenceArray(针对数组中的元素)。

Updater,可以在原有的类定义volatile基础上实现原子性管理,而不需要将变量定义为Atomic的,这样可以在不破坏原有代码逻辑的基础上实现一致性读写。

atomic原子加和问题



加入10个线程同时对一个atomic变量进行加1操作,每个线程加100次,那么不管运行多少次,最后的结果都是1001,不会改变。

如果是volatile的话,结果是随机的。

AtomicStampedReference解决ABA问题



当需要把数据A变为B,如果用atomic,那么只有一个请求会成功,如果这时其他请求尚未被调度,这时如果另一个线程把B变回为A,那么尚未被调度的请求被调度时认为对象还是原先的A,会再次改为B。

比如小明去取钱取100块,由于取款机问题重复提交了两次,服务器端为了避免问题,使用CAS,即将账户金额取出之后进行回写时要带上原来的金额作为条件,如果对不上,则不会修改数据,避免并发时减掉两次钱。但是如果并发时这两次减钱操作先后执行,小明的妈妈又重入了100元,在第一次减掉之后,金额加了100又恢复了之前的余额,这时就会被减掉两次。

这个时候使用AtomicStampedReference,它在修改的过程中不只比较值,也比较版本号,它提供getReference和getStamp获得引用和版本号,在compareAndSet时带上老的版本号和新的版本号。

AtomicMarkableReference与AtomicStampedReference类似,但是它的状态只有两种——为改变或已改变,而不是像后者那样可以有多个版本号。如果扣了100元,标志位设置为“已改变”,如果再重入100元,那么这个标志位不允许改变,那么及时总金额一样,也可以区分是否是最原始状态了。

Atomic原理


atomic许多操作都源自于对比、修改动作。以AtomicInteger为例看下源码。

incrementAndGet(){for(;;){

int current = get();int next = current + 1;

if(compareAndSet(current, next){ return next;})}}

该代码时死循环操作,循环内首先获取一个值,然后当前值+1得到next值,再通过compareAndSet将当前值改为next。

compareAndSet方法是CAS的,返回一个Boolean变量,表示是否修改成功。若成功就返回next值,若失败就继续循环上述操作——自旋

incrementAndGet返回next值,getAndIncrement返回的是之前的值。

compareAndSet方法中调用了unsafe的compareAndSwapInt操作。compareAndSwapLong操作要根据本地VMSupportCS8来决定JVM是否支持8字节CAS操作,如果不支持,会采用的方式来实现。

Unsafe


顾名思义,如果操作不当会十分不安全。通过它可以操作一些在java程序中不太方便操作的内容。前面说的“直接内存”就是unsafe提供的API来完成的。不过这种API的使用通常是二次封装,如果自己用就可能会有些问题。

unsafe中有很多native方法,在AtomicInteger中就使用了objectFieldOffset方法获取属性。

总之,它基于可见性,修改前提取,修改后对比来确定是否写回到主存,最终达到原子性。

Lock


除了synchronized,java还提供了许多纯语言级别的锁。

ReadLock和WriteLock称为读写锁,ReentrantLock是排它锁,是完全互斥的。

不管哪种锁,都会提供最基本的两个操作,lock和unlock,代码一般是这样的:

lock.lock();try...catch...finally{lock.unlock();}

通过lock可以实现临界资源的同步,相对于synchronized语法要稍微复杂一些,但是可以让使用者更清楚的看到锁的粒度是基于lock这个对象的。那么要优化粒度,就在这个锁对象级别。

另外它是在java语言层面通过CAS自旋方式来实现锁的,并发情况下性能比synchronized要好一些。因为程序一旦发生征用synchronized就可能进入悲观锁状态。JDK1.7以后很多java类中的锁都从synchronized改为了lock。

lock除了CAS还有一个AQS机制,AQS同时也是并发编程许多组件的基础,例如CountDownLatch,Semaphore都是基于AQS的。

AQS——AbstractQueueSynchronizer讲解之ReentrantLock



构造方法:public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}

NonfairSync

lock,会通过CAS尝试将状态从0改为1。如果修改成功(前提条件自然是锁状态为0),则直接将线程的Owner改为当前线程。

如果这个动作没成功,会调用acquire(1)->tryAcquire(1)>nonfairTryAcquire(1)

首先获取这个锁的状态:

①如果状态为0,则尝试设置状态为传入的参数(这里的参数值就是1),设置成功就代表获取到了锁,返回true。

②如果状态不为0,判断当前线程是否是排它锁的Owner,如果是,则尝试增加acquires值,如果没有越界,就把acquires设置到状态中,然后返回true(实现了类似偏向的功能,可重入,但无须进一步征用)。

③如果状态不为0,而且当前线程不是Owner,返回false。

public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

如果tryAcquire返回true,那么acquireQueued不会执行,这种情况是最乐观的。但是无论多么乐观,征用必然存在,如果征用存在,Owner肯定不是自己,tryAcquire会返回false,就会调用acquireQueued方法。

acquireQueued中的addWaiter方法

该方法会调用addWaiter(Node node),Node中包含当前线程节点的mode。可以看出来AQS是一个基于状态(state)的链表管理方式。

private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // 第一次操作,tail和head都是null,进入该逻辑
if (compareAndSetHead(new Node()))
tail = head;
} else {// 非第一次操作,tail已经初始化
node.prev = t;
if (compareAndSetTail(t, node)) {//CAS操作把tail设置为node,不成功继续循环
t.next = node;
return t;
}}}}

通过上述操作可以知道,AQS的写入是一种双向链表的插入操作,可以插入多个节点,节点都是在链表尾部插入的,而且是线程安全的。

acquireQueued方法

final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {//只有当传入的node的前一个节点是head的时候,进一步通过tryAcquire来征用才有机会成功(当前锁state是0,且通过CAS设置state成功;或者排它锁的Owner是当前线程——线程的持有者本身是当前线程)。

setHead(node);//三个操作,head = node;node.thread = null;node.prev = null;将node作为AQS的head指向的节点,线程属性设置为空,因为已经获取到锁,不再需要记录这个节点所对应的线程
p.next = null; // help GC,释放原先的头节点,对应上面的node.prev = null,断链
failed = false;
return interrupted;
}

通过这样的方式可以让执行完的节点释放内存区域,而不是无限制的增长队列,更像FIFO的处理。

if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())// 将当前线程挂起到WAITING状态,需要中断或者unpark方法来唤醒它
interrupted = true;

注意:lock的次数和unlock的次数一定要对应上,如果调用了两次lock,调用一次unlock,最终可能无法释放锁。要保证unlock方法一定要执行,最好放在finally中执行。

lock与AQS的补充


lock不仅仅局限于lock和unlock操作,因为线程如果进入waiting状态,这时没有unpark就无法唤醒它,可以使用tryLock或者tryLock(long, TimeUnit)来做一些尝试加锁或超时来满足某些特定场景的需要。如果有时候尝试加锁无法成功,那么可以先释放对其他对象已经成功添加的锁,这样可以避免死锁。

Node的状态


①signal:代表自己正在等待前面的线程结束,调用unpark方法才能激活自己,值为-1。

②cancelled:征用锁过程中抛出异常调用fullyRelease方法时会处于这种状态,值为1,几种状态中唯一大于0的状态。

③condition:线程基于condition对象发生了等待,进入了相应的队列,需要condition对象来激活,值为-2。

④0:初始化状态,代表正在尝试去获取临界资源的线程所对应的Node的状态。

自旋优化


前面说过用CAS来修改数据,将这种操作做一个while(times<最大次数){...尝试},即不断进行CAS尝试,但是不是死循环。前面AQS也大量采取了这种操作,JVM的底层锁也逐步通过这样的思路改善。因为乐观,所以认为尝试的次数不会太多,如果太多,就转为悲观,将线程阻塞,否则会在这里空耗CPU

乐观可以自己有尝试有选择,在尝试一定次数之后,可以选择离开不再尝试。而悲观是它自己在被动的调度,不论多久都誓死要拿到结果。

尝试的这个过程通常叫做自旋,这种锁在JDK1.4.2就引入了。JDK1.6以后自旋开始智能化,在某些情况下可以自己调节自旋的次数,次数的设置是由历史代码请求自旋成功的概率和次数来决定的。

synchronized优化


synchronized会在对象的头部打标记,这个加锁的动作是必须要做的。悲观锁通常还会做其他的指令动作,轻量级锁希望通过CAS实现,因为它很乐观,认为通过CAS修改对象头部的mark区域内容就可以达到目的,因为mark区域4~8字节,一个int或者long的长度,十分适合CAS操作。

轻量级锁通常会做下面四个步骤:

①在栈当中分配一块空间用来做一份对头像的mark word的拷贝,在mark work中将对象锁的二进制设置为“未锁定”,即01,这个动作是方便等到释放锁的时候,将这份数据拷贝到对象头部。

②通过CAS尝试将头部的二进制位修改为“线程私有栈中对mark区域拷贝存放的地址”,如果成功,会将最后2位置为00,代表被轻量级锁锁住了。

③如果没成功,判定对象头部是否已经指向当前线程所在的栈中,如果成立代表当前线程已经是拥有者,而已继续执行。

④如果不是拥有者,说明有多个线程在征用,那么此时会将锁升级为悲观锁,线程即进入blocked状态。

偏向锁



JVM发现在轻量级锁中多次“重入”或者“释放”时,需要做的判定和拷贝动作较多,在某些应用中,锁就是被某一个线程一直使用。为了进一步减小锁的开销,JVM中出现了偏向锁。偏向锁记录的是一个线程的ID,比轻量级锁还轻量,再次重入时,先判定对象头部的线程ID,如果是则表示当前线程已经是对象锁的Owner了,无须做任何其他动作了

①线程竞争时,如果没有其他线程征用,会尝试CAS修改mark word中的一个标记为偏向,这个CAS同时会修改mark word部分bit以保留线程的ID值。

②线程不断发生重入的话,判定很简单,只需要判定头部的线程ID是不是当前线程,如果是,则不用做任何其他CAS操作了。

③如果同一个对象正在另一个线程中发起了请求,会先判断该对象是否被锁定,如果是,会将锁改为轻量级的(00),即锁粒度上升,如果没有锁定,会将对象的是否偏向位置设置为不可偏向。

如果锁定了这个对象,就会最少使用轻量级锁,因为轻量级锁的征用上升依然会进入悲观锁。所以偏向锁只是解决没有任何锁征用的场景,当出现锁征用就没什么用途了。


0 0