java多线程
2021-05-12 11:28:00 12 举报
AI智能生成
java多线程
作者其他创作
大纲/内容
Lock
- 带有高速缓存的CPU执行计算的流程
1. 程序以及数据被加载到主内存
2. 指令和数据被加载到CPU的高速缓存
3. CPU执行指令,把结果写到高速缓存
4. 高速缓存中的数据写回主内存 - CPU缓存一致性协议(MESI):
缓存行(Cache line):缓存存储数据的单元。
四种状态:修改-Modified、独享-Exclusive、共享-Shared、无效-Invalid
实现方式:1. 总线锁;2. 缓存锁;
- 锁升级:无锁 -> 偏向锁 -> 自旋锁(轻量级锁,自旋10次锁升级) -> 重量级锁
CAS
- 概述:
CAS(compare and swap)操作属于乐观锁(又称自旋锁),
每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。 - java相关类:
包java.util.concurrent.atomic中多数类使用CAS操作,最终使用的是sun.misc.Unsafe相关 native boolean compareAndSwap*本地方法。
例如 AtomicInteger中 boolean compareAndSet(int expect, int update),调用的是unsafe.compareAndSwapInt方法。
之后调用C++的Atomic::cmpxchg方法实现比较替换。之后调用cpu指令lock cmpxchg,如果是单核的话,不需要lock前缀。
- 底层机制:
使用了3个基本操作数:内存值V,旧的预期值A,要修改的新值B。
当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。 - 缺点:
1. cpu开销较大:并发量高时,如果多个线程反复尝试更新某一个变量不成功,会给cpu带来很大压力。
2. 不能保证代码块的原子性:CAS机制所保证的只是一个变量的原子性操作,不能保证整个代码块的原子性。
比如需要保证3个变量共同进行原子性更新,就不得不使用Synchronized了。
volatile
- 保证线程可见性:
1. 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
2. 不能保证原子性。可使用Lock、synchronized、java.util.concurrent.atomic 包下的类实现。 - 禁止指令重排序
内存屏障保证了位于内存屏障之前的所有操作先完成于内存屏障后面的所有操作。所以指令无法越过内存屏障,也就是无法重排序。
原语指令:loadfence、storefence。
ThreadLocal
void set(T value)
Thread.currentThread.ThreadLocalMap.set(ThreadLocal, value); // 设到了当前线程的map中
Thread.currentThread.ThreadLocalMap.set(ThreadLocal, value); // 设到了当前线程的map中
T get()
Thread.currentThread.ThreadLocalMap.get(ThreadLocal); // 获取当前线程的map中的value
Thread.currentThread.ThreadLocalMap.get(ThreadLocal); // 获取当前线程的map中的value
实例使用:spring的声明式事务实现,把数据库的connection对象放到TheadLocal中,同一个线程追溯的多个方法就能获取到同一个数据库的连接对象,进而利用数据库的事务实现数据的原子性。
synchronized
概述:
早期1.6之前,synchronized是重量级锁,申请加锁阻塞或唤醒一条线程均需要操作系统帮忙完成,这就需要调用从用户态转换到内核态(0x80中断-system_call系统调用),状态转换需要耗费很多cpu时间。所以synchronized是一个重量级操作。1.6中加入了针对锁的优化。
早期1.6之前,synchronized是重量级锁,申请加锁阻塞或唤醒一条线程均需要操作系统帮忙完成,这就需要调用从用户态转换到内核态(0x80中断-system_call系统调用),状态转换需要耗费很多cpu时间。所以synchronized是一个重量级操作。1.6中加入了针对锁的优化。
- 对象的内存部局(可参考 JVM内存部局篇章)
MarkWord(8字节)、ClassPointer(默认压缩4字节)、instance data、padding(填充对齐,使对象占用空间大小为8的整数倍)。
前两个组成可称为对象头。MarkWord中记录了对象的GC信息、hashcode、锁信息。
锁信息存储在MarkWord中64位的最低两位。无锁态和偏向锁最低两位相同,还需要第三位加以区分。 - 锁类型及锁升级
无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。
锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。
- 1. 偏向锁:
大多数情况下锁不存在多线程竞争,总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的CAS操作。
获取过程:
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
(1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。
(2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。
(3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。
(4)如果CAS获取偏向锁失败,则表示有竞争(CAS获取偏向锁失败说明至少有过其他线程曾经获得过偏向锁,因为线程不会主动去释放偏向锁)。当到达全局安全点(safepoint)时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁),如果线程不处于活动状态,则将对象头设置成无锁状态(标志位为“01”),然后重新偏向新的线程;如果线程仍然活着,撤销偏向锁后升级到轻量级锁状态(标志位为“00”),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
(5)执行同步代码。
释放过程:
如上步骤(4)。偏向锁使用了一种等到竞争出现才释放偏向锁的机制:偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
关闭偏向锁:
偏向锁在Java 6和Java 7里是默认启用的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。 - 2. 轻量级锁
这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒。
加锁过程:
(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态,虚拟机首先将锁对象的对象头MarkWord复制一份到当前线程的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为当前线程的锁记录的地址。
(2)拷贝对象头中的Mark Word复制到锁记录中。
(3)拷贝成功后,虚拟机将使用CAS操作尝试将锁对象的Mark Word更新为指向Lock Record的指针地址,并将Lock Record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。
(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。
(5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,若当前只有一个等待线程,则可通过自旋稍微等待一下,可能另一个线程很快就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
解锁过程:
(1)通过CAS操作尝试把锁对象的Mark Word修改为线程中复制的Displaced Mark Word对象指针地址。
(2)如果替换成功,整个同步过程就完成了。
(3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
- 3. 重量级锁
如上轻量级锁的加锁过程步骤(5),轻量级锁所适应的场景是线程近乎交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。Mark Word的锁标记位更新为10,Mark Word指向互斥量(重量级锁)
Synchronized的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。
AQS
概述:抽象队列式同步器,定义一套多线程访问共享资源的同步器框架,依赖其的同步类有 ReentrantLock、Semaphore、CountDownLatch 等
- AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。 - 它维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)
volatile是核心关键词,且通过 compareAndSetState (CAS) 原子性操作state的值。 - 其中还使用到了模版方法设计模式,如 子类NonfairSync重写了父类Sync的父类AbstractQueuedSynchronizer的tryAcquire(int arg)方法
获取锁重要方法:
- acquire(int)
此方法是独占模式下线程获取共享资源的顶层入口。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。获取到资源后,线程就可以去执行其临界区代码了。
函数流程如下:
1. tryAcquire()尝试直接去获取资源,如果成功则直接返回(这里体现了非公平锁,每个线程获取锁时会尝试直接抢占加塞一次,而CLH队列中可能还有别的线程在等待);
2. addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
3. acquireQueued()使线程阻塞在等待队列中获取资源,一直获取到资源后才返回。如果在等待过程中被中断过,返回true,否则返回false。
4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。 - tryAcquire(int)
此方法尝试去获取独占资源。如果获取成功,则直接返回true,否则直接返回false。
AQS只是一个框架,具体资源的获取/释放方式交由自定义同步器去实现tryAcquire方法。 - addWaiter(Node)
此方法用于将当前线程加入到等待队列的队尾,并返回当前线程所在的结点。 - acquireQueued(Node, int)
通过tryAcquire()和addWaiter(),该线程获取资源失败,已经被放入等待队列尾部了。此方法使用线程进入等待状态休息,直到其他线程彻底释放资源后唤醒自己,自己再拿到资源,然后就可以去干自己想干的事了。 - shouldParkAfterFailedAcquire(Node, Node)
此方法主要用于检查状态,看看自己是否真的可以去休息了(进入waiting状态),防止队列前边的线程都放弃了只是瞎站着,那也说不定! - parkAndCheckInterrupt()
如果线程找好安全休息点后,那就可以安心去休息了。此方法就是让线程去休息,真正进入等待状态。
LockSupport.park()会让当前线程进入waiting状态。在此状态下,有两种途径可以唤醒该线程:1)被unpark();2)被interrupt()。
需要注意的是,Thread.interrupted()会清除当前线程的中断标记位。
释放锁重要方法:
- release(int)
是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。
- tryRelease(int)
此方法尝试去释放指定量的资源。跟tryAcquire()一样,这个方法是需要独占模式的自定义同步器去实现的。
正常来说,tryRelease()都会成功的,因为这是独占模式,该线程来释放资源,那么它肯定已经拿到独占资源了,直接减掉相应量的资源即可(state-=arg),也不需要考虑线程安全的问题。
但要注意它的返回值,上面已经提到了,release()是根据tryRelease()的返回值来判断该线程是否已经完成释放掉资源了!所以自义定同步器在实现时,如果已经彻底释放资源(state=0),要返回true,否则返回false。
- unparkSuccessor(Node)
此方法用于唤醒等待队列中下一个线程。
一句话概括:用unpark()唤醒等待队列中最前边的那个未放弃线程,这里我们也用s来表示吧。此时,再和acquireQueued()联系起来,s被唤醒后,进入if (p == head && tryAcquire(arg))的判断(即使p!=head也没关系,它会再进入shouldParkAfterFailedAcquire()寻找一个安全点。这里既然s已经是等待队列中最前边的那个未放弃线程了,那么通过shouldParkAfterFailedAcquire()的调整,s也必然会跑到head的next结点,下一次自旋p==head就成立啦),然后s把自己设置成head标杆结点,表示自己已经获取到资源了,acquire()也返回了!!And then, DO what you WANT!
Thread
static native void sleep(Long millis)
static native void yield()
void join()
<Lock>
ReentrantLock
ReentrantLock
void lock()
void unlock()
boolean tryLock()
void lockInterruptibly()
Condition newCondition()
condition.await()
condition.signal()
LockSupport
static void park()
static void unpark(Thread)
CountDownLatch
CountDownLatch(int)
void countDown()
void await()
CyclicBarrier
CyclicBarrier(int, <Runnable>)
int await()
Phaser
Phaser
<ReadWriteLock>
ReentrantReadWriteLock
ReentrantReadWriteLock
class ReadLock implements Lock
class WriteLock implements Lock
ReentrantReadWriteLock.ReadLock readLock()
ReentrantReadWriteLock.WriteLock writeLock()
Semaphore
Semaphore(int)
void acquire()
void release()
Exchanger
V exchange(V x)
ReentrantLock流程图
0 条评论
下一页