ZhouXiangの博客 后端工程师&机器学习爱好者

Java学习系列之多线程进阶

2019-10-10

本文系记录对Java中多线程的学习资料,如有异议,欢迎联系我讨论修改。PS:图侵删!如果看不清图请访问:https://github.com/zx950519/zx950519.github.io/blob/master/_posts/2019-10-06-Java%E5%AD%A6%E4%B9%A0%E7%B3%BB%E5%88%97%E4%B9%8B%E5%A4%9A%E7%BA%BF%E7%A8%8B%E8%BF%9B%E9%98%B6.md

概述

在Java多线程中,常见手段有synchronized、Lock与volatile这几种,本文对上述几种技术进行简要分析。

synchronized的内部实现以及优化

当一个线程试图访问同步代码块时,它必须先得到锁,退出或抛出异常的时候必须释放锁。Synchronized的原理是JVM基于进入和退出Monitor对象来实现同步和代码块同步。代码块同步使用monitorenter指令和monitorexit指令来实现。monitorenter指令是插入到同步同步块开始的位置,而monitorexit是插入到方法结束处和异常处。JVM要保证每个monitorenter指令和monitorexit指令匹配。任何对象都有一个monitor与之关联,当有一个monitor被持有后,它将处于锁定状态。线程执行monitorenter指令时,将会尝试获取对象所对应的monitor的所有权吗,即尝试获得对象的锁。 Synchronized的优化即JDK1.6以后引入的偏向锁、轻量级锁。

什么是Java对象头?

synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

什么是Monitor?

Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

synchronized的优点和缺点

  • 若干线程交叉访问同一对象的同步方法时,一定线程安全(关键字取得的锁都是对象锁)。
  • 两个线程访问同一个对象的A方法,如果方法A是Synchronized的,那么将会同步访问;如果线程1访问Synchronized的A方法,线程2访问非Synchronized的B方法,则会异步地访问。
  • 一个Synchronized方法/块的内部调用本类的其他Synchronized方法/块时,是永远可以得到锁的(Synchronized的可重入性)。
  • 当存在父子继承关系时,子类可以通过可重入锁调用父类的同步方法。
  • Synchronized当出现异常时,自动释放锁;ReentrantLock则必须手动unlock()。
  • Synchronized同步不具有继承性。
  • 若干线程并发访问同一对象中的Synchronized(this)同步代码块时,只有一个线程可执行。
  • 当一个线程访问Synchronized(this)时,其他线程访问同一个对象实例的所有其他Synchronized(this)时(不管是不是同一个块)将会被阻塞,因为Synchronized使用了同一个对象监视器。
  • Synchronized加到static上对类上锁,加到非static上对实例上锁。
  • 大多数情况下,同步Synchronized代码块不适用String作为锁对象,因为可能会造成死锁。
  • 多线程争夺的锁对象不变,即使锁对象的属性改变,依旧是同步的。
  • Synchronized不仅可以解决一个线程看到对象处于不一致的状态,还可以保证进入同步方法或同步代码块的每个线程,都看到由同一个锁保护之前所有的修改结果。

锁的分类

乐观锁与悲观锁

乐观锁与悲观锁并不是特指某个锁,而是在并发情况下保证数据完整性的不同策略。悲观锁指的就是我们平常使用的加锁机制,它假设我们总是处于最坏的情况下,如果不加锁数据完整性就会被破坏。而乐观锁指是一种基于冲突检测的方法,检测到冲突时操作就会失败。

乐观锁是一种乐观思想,认为读多写少,遇到并发写的可能性低,每次去拿数据的时候认为其他线程不会上说。在更新时会判断该段时间内有没有其他线程更新这个数据,先读取当前版本号,然后比较上一次版本号,如果一样则更新;不一样重复上述过程或放弃。乐观锁适用于多读的应用类型,这样可以提高吞吐量。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现。

悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,认为写多读少,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞,直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如Java里面的synchronized关键字的实现也是悲观锁。AQS框架下的锁则是先尝试CAS乐观锁去获取锁,获取不到锁再转化为悲观锁,如RetreenLock。

乐观锁存在的问题:

  • ABA问题。
  • 循环时间长开销大。自旋CAS(不成功,就一直循环执行,直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。
  • 只能保证一个共享变量的原子操作。

悲观锁存在的问题:

  • 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
  • 一个线程持有锁会导致其它所有需要此锁的线程挂起。
  • 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

可重入锁

可重入锁是一种可以再次获得自己的内部锁。当某个线程A已经持有了一个锁(对象锁),当线程B尝试进入被这个锁保护的代码段的时候,就会被阻塞。同一个线程再次进入同步代码的时候,可以使用自己已经获取到的锁,这就是可重入锁。java里面内置锁(synchronize)和Lock(ReentrantLock)都是可重入的。当存在父子继承关系时,子类可以通过可重入锁调用父类的同步方法。自旋锁是一种不可重入锁。

可重入锁的实现:
为每个锁关联一个获取计数器和一个所有者线程,当计数值为0的时候,这个锁就没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增;退出一次同步代码块,计数值递减;当计数值为0时,这个锁就被释放。

共享锁和独占锁

  • 独占锁,当一个线程获取了锁,其它线程就不能获取到锁,必须等锁释放了,才能可能获取到锁。例如:synchronized和ReentrantLock
  • 共享锁,是可以多个线程获取到锁,但不是任何线程都可以共享锁。典型的就是ReentrantReadWriteLock里的读锁,它的读锁是可以被共享的,但是它的写锁确每次只能被独占

公平锁与非公平锁

  • 公平锁在加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得
  • 非公平锁再加锁时不考虑排队等待,直接尝试获得锁,获取不到自动到对位等待

非公平锁效率比公平锁高5-10倍,因为公平锁需要在多核环境下维护一个队列。synchronized和ReentrantLock中默认的lock()方法都是非公平锁。之所以非公平锁效率高,是因为非公平锁在state=0时直接利用CAS修改状态,然后获得锁;state!=0时,再次调用CAS尝试修改获取锁,从而方便新来的线程进行插队,如果抢占失败则进入等待队列,在队列中进入睡眠前还会进行一次tryAcquire()调用做最后的尝试。通过多次尝试,避免上下文切换以及过多队列操作的成本,从而提高效率。

自旋锁

自旋锁是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待而不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自悬),等持有锁的进程释放锁后即可获得锁,这样就会避免用户线程和内核的切换和消耗。如果持有锁的进程在自旋的最大等待时间内依旧没有获取到锁,争用线程会停止自旋进入阻塞状态。

自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。

自旋锁的优缺点:

  • 对于锁竞争不激烈且占用锁时间非常短的代码块而言,自旋会大幅度提升性能。因为自旋的消耗会小于线程阻塞挂起再唤醒的操作消耗,这些操作会导致线程发生两次上下文切换。
  • 如果锁竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,此时不适合使用自旋锁,此时自旋只会白白占用CPU。同时有大量线程竞争一个锁,导致获取所的时间很长,自旋消耗大于线程阻塞挂起操作的消耗。

分段锁

假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

偏向锁

在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获得锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试下对象头里的MarkWord中是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,需要再测试下MarkWord中偏向锁的标识是否为1;如果未设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的释放,需要等待全局安全点。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否存活,如果不处于活动状态,则将对象头设置为无锁状态;如果线程还活着,持有偏向锁的栈会被执行,遍历偏向对象的锁记录 ,栈中的锁记录和对象头的Mark Word要么重新偏向其他线程,要么恢复到无锁或标记对象不适合作为偏向锁,最后唤醒暂停的线程。

0996d9eac763ced3756d4a1012ea13f.jpg

d0c787df8e9662c7fcb31ef10bc0d97.jpg

轻量级锁

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的MarkWord。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程采用自旋来获得锁。在轻量级解锁时,使用原子CAS将Displaced MarkWord替换回对象头,如果成功则表示没有竞争发生,如果失败则表示当前锁存在竞争,锁会膨胀为重量级锁。因为自旋会消耗CPU,为了避免无用的自旋,一旦膨胀为重锁,将不会再次降级。

bb4ad28e99cb52907893a0708c998c2.jpg

重量级锁

几种锁在MarkWord中的标志位

bbabbff677ee3c15300f0a9035ff357.jpg

几种锁的优缺点对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外消耗,和执行非同步方法仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销消耗 适用于一个线程发放稳同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,自旋会消耗CPU 追求响应时间,同步块的执行速度非常快
重量级锁 线程竞争不会使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步时间执行速度较长

锁膨胀的过程

在JDK1.6中,为了减少获得锁和释放锁的性能消耗,引入了偏向锁和轻量级锁。因此,锁一共有四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,这几个情况会随着竞争情况逐渐升级。锁只能单向膨胀,无法降级,这样设计是为了提高获得锁和释放锁的效率。锁膨胀的过程为:

  • 偏向锁
  • 轻量级锁
  • 重量级锁

锁清除与锁粗化

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码。

通常情况下,为了保证多线程间的有效并发,会要求每个线程有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能优化。

锁的内部实现以及优化

概述

Java中常见的一些锁例如:ReetrantLock、读写锁、CountDownLatch以及CyclicBarrier,本质上都是基于Lock接口与AQS实现,本小节将对这些内容进行简要介绍,重心将放在ReetrantLock上,感兴趣的同学可以自行搜索其他内容。

ReetrantLock的内部实现

ReentrantLock继承接口Lock并实现了接口中定义的方法,它是一种可重入独占锁,支持公平锁与非公平锁,除了能完成synchronized所能完成的所有工作外,还提供了注入可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。
下面给出Lock接口中常见的一些方法:

ReetrantLock的内部实现涉及到计数值、双向链表(AQS)、CAS+自旋。在ReetrantLock中的属性继承体系中,最终定位到AQS。ReetrantLock的大致实现基于双向链表CLH+与一个被volatile修饰的int类型计数器state的状态。

Lock中提供的一下基本功能:

  • 等待中断,ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情
  • 提供公平锁,所谓的公平锁就是先等待的线程先获得锁。 ReenTrantLock默认情况是非公平的,可以通过 ReenTrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的
  • Condition,Synchronized关键字与wait()和notify/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition() 方法。Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。在使用notify/notifyAll()方法进行通知时,被通知的线程是由JVM选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知”,这个功能非常重要,而且是Condition接口默认提供的。而Synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。

ReetrantLock如何实现公平锁与非公平锁

ReentrantLock的公平锁和非公平锁都委托了AbstractQueuedSynchronizer#acquire去请求获取。

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

tryAcquire 是一个抽象方法,是公平与非公平的实现原理所在。addWaiter 是将当前线程结点加入等待队列之中。公平锁在锁释放后会严格按照等到队列去取后续值,而非公平锁在对于新晋线程有很大优势。如果线程在阻塞期间发生了中断,调用 Thread.currentThread().interrupt() 中断当前线程。公平锁和非公平锁在锁的获取上都使用到了volatile关键字修饰的state字段,这是保证多线程环境下锁的获取与否的核心。但是当并发情况下多个线程都读取到 state == 0时,则必须用到CAS技术,一门CPU的原子锁技术,可通过CPU对共享变量加锁的形式,实现数据变更的原子操作。volatile和CAS的结合是并发抢占的关键。非公平锁与公平锁的区别在于新晋获取锁的进程会有多次机会去抢占锁。如果被加入了等待队列后则跟公平锁没有区别。

undefined

通过上图中的源代码对比,我们可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。再进入hasQueuedPredecessors(),可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。

CountDownLatch与CyclicBarrier的区别及使用场景

粗浅的区别在于:

  • CountDownLatch是一次性的,而 CyclicBarrier在调用reset之后还可以继续使用
  • CountDownLatch计数器为减法,CyclicBarrier为加法

高端区别在于其实现以及语义上的区别:

  • CountDownLatch : 一个线程(或者多个),等待另外N个线程完成某个事情之后才能执行
  • CyclicBarrier: N个线程相互等待,任何一个线程完成之前,所有的线程都必须等待

对于CountDownLatch来说,重点是那个“一个线程”,是它在等待,而另外那N的线程在把“某个事情”做完之后可以继续等待,可以终止。而对于CyclicBarrier来说,重点是那N个线程,他们之间任何一个没有完成,所有的线程都必须等待。

一个生动的比喻如下:

  • CountDownLatch是计数器,线程完成一个就记一个,就像 报数一样,只不过是递减的
  • CyclicBarrier更像一个水闸,线程执行就想水流,在水闸处都会堵住,等到水满(线程到齐)了,才开始泄流

CountDownLatch实质上就是一个AQS计数器,通过AQS来实现线程的等待与唤醒:

  • 调用CountDownLatch的countDown方法时,N会减1,CountDownLatch的await方法会阻塞主线程直到N减少到0
  • 调用await方法的实质是在获取同步状态,同步状态state==0成立,当前等待完成的点均已完成,主线程继续往下执行,否则,主线程进入等待队列自旋等待直到同步状态释放后state==0。有些时候主线程是不能一直自旋等待,这个时候带超时时间的await就派上用场了,设置超时时间,如果在指定时间内N个点都未完成,返回false,主线程不再等待,继续往下执行。

创建CyclicBarrier后,每个线程调用await方法告诉CyclicBarrier自己已经到达同步点,然后当前线程被阻塞。CyclicBarrier同样提供带超时时间的await和不带超时时间的await。整个await方法的核心是dowait方法的调用。在dowait的前段部分,主要完成了当所有线程都到达同步点(barrier)时,唤醒所有的等待线程,一起往下继续运行,可根据参数barrierAction决定优先执行的线程。在dowait的实现后半部分,主要实现了线程未到达同步点(barrier)时,线程进入Condition自旋等待,直到等待超时或者所有线程都到达barrier时被唤醒。

Lock的灵魂——AQS

AQS(抽象队列同步器)是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。

AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。

AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。

private volatile int state;     //共享变量,使用volatile修饰保证线程可见性

状态信息通过protected类型的getState,setState,compareAndSetState进行操作。

//返回同步状态的当前值
protected final int getState() {  
        return state;
}
 // 设置同步状态的值
protected final void setState(int newState) { 
        state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

AQS对资源的共享方式:

  • 独占:只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁。
  • 共享:多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatCh、 CyclicBarrier、ReadWriteLock。

Volatile的内部实现以及优化

Volatile是轻量级的Synchronized,它在多处理器中保证了共享变量的可见性。如果Volatile使用恰当的话,其使用和执行成本比Synchronized更低,因为它不会引起线程上下文切换和调度。利用Volatile修饰的共享变量,会在对共享变量写入时插入一条Lock前缀指令,并引发下面两个事件:

  • 将当前处理器缓存行的数据写回到系统内存
  • 这个写回内存的操作会使其他CPU里缓存该数据的内存地址的数据无效

在多处理器环境下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探总线上传播的数据来检查自己缓存的值是否过期

什么是指令重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分为3种类型:

  • (编译器)编译器优化的重排序:编译器在不改变单线程语义的前提下,可以重新安排语句的执行顺序
  • (处理器)指令级并行重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • (处理器)内存系统的重排序:由于处理器使用缓存/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

可以看书,指令重排序大致分为两类,分别是编译器重排序和处理器重排序。上述两种重排序均会导致内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序;对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障,通过内存屏障指令来禁止特定类型的处理器重排序。

内存屏障的分类

屏障类型 指令示例 说明
读读 Load1;LoadLoad;Load2 确保Load1数据的装载优先于Load2及后续装载指令
写写 Store1;StoreStore;Store2 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及后续存储指令的存储
读写 Load1;LoadStore;Store2 确保Load1数据装载先于Store2及后续的存储指令刷新到内存
写读 Store1;StoreLoad;Load2 确保Store1数据对其他处理器可见(刷新到内存)先于Load2及后续装载指令的装载。写读屏障会使该屏障之前的内存访问指令完成后,才执行该屏障之后的内存访问指令

顺序一致性模型与JMM模型的区别

  • 顺序一致性模型保证单线程内部操作按顺序执行;JMM没有此项担保
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序;JMM不保证所有线程看到一致的顺序
  • 顺序一致性模型保证所有类型的内存读写具有原子性;JMM不保证对64为long型和double型变量的写操作具有原子性

Volatile读写内存的语义

  • 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效。线程接下来将会从主内存中读取共享变量。实质上接受了之前某个线程发出的消息(对这个volatile变量已进行修改)
  • 当写一个volatile变量时,实质上也是该线程向接下来要读这个volatile变量的某个线程发出了消息(对共享变量)

无锁并发编程——CAS

概述

无锁并发编程CAS,底层采用sun包的Unsafe类,感兴趣的同学请自行深入学习。

处理器如何实现原子操作

  • 使用总线锁:使用处理器提供的一个Lock #信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,该处理器可独占共享内存。但是,总线锁的开销较大。
  • 使用缓存锁:即缓存锁定,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使得缓存行无效。

CAS实现原子操作的三大问题以及解决方案

  • ABA问题:略;使用AtomicStampedReference解决,该类具有带版本号的功能。
  • 循环时间过长:自旋CAS如果长时间不成功,会给CPU带来极大的执行开销;如果JVM支持处理器提供的pause指令,那么效率会有一定的提升。
  • 只能保证一个共享变量的原子操作:多个共享变量操作时,无法保证原子性;使用锁或将多个共享变量合成为一个类然后使用AtomicReference保证引用对象间的CAS操作。

CAS的实现原理

Java中CAS操作的执行依赖于Unsafe类的方法。Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,单从名称看来就可以知道该类是非安全的,毕竟Unsafe拥有着类似于C的指针操作,因此总是不应该首先使用Unsafe类,Java官方也不建议直接使用的Unsafe类。

并发手段对比

CAS与Synchronized对比

  • 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
  • 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
    synchronized在jdk1.6之后,已经改进优化。synchronized的底层实现主要依靠Lock-Free的队列,基本思路是自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。

Lock与Synchronized对比

  • 在资源竞争不是很激烈的情况下,偶尔会有同步的情形下,synchronized是很合适的。原因在于,编译程序通常会尽可能的进行优化;另外,可读性上较好
  • 由于ReentrantLock但是当同步非常激烈的时候,还能维持常态。所以比较适合高并发的场景
  • 在一些synchronized所没有的特性的时候,比如时间锁等候、可中断锁等候、无块结构锁、多条件轮询锁
  • 与目前的synchronized实现相比,争用下的ReentrantLock实现更具可伸缩性。这意味着当许多线程都在争用同一个锁时,使用ReentrantLock的总体开支通常要比synchronized少得多

Lock和Synchronized的区别是什么

  • 两者都是可重入锁
  • 两者都保证了可见性与互斥性
  • synchronized依赖于JVM(JVM级别)而ReenTrantLock依赖于API(API级别)
  • ReenTrantLock可以等待中断,而synchronized不行
  • ReenTrantLock可以有公平锁,而synchronized没有
  • ReenTrantLock可以绑定多个Condition条件
  • ReenTrantLock显示获得、释放锁,而synchronized隐式获得锁
  • Lock是同步非阻塞采用乐观并发策略,而synchronized是同步阻塞采用悲观并发策略
  • Lock是接口,而synchronized是关键字
  • Lock在发生异常时,如果没有主动unLock()解锁会出现死锁,必须在finally中解锁,而synchronized发生异常时,自动释放锁
  • Lock可以知道有没有成功获得锁,而synchronized不行
  • Lock可以提高多个线程进行读写的效率,例如读写锁,而synchronized不行

Similar Posts

Comments