21 June 2020

线程安全点

当线程执行到这些位置的时候,说明虚拟机当前状态是安全的,如果需要,可以在这个位置暂停,比如发生GC,需要执行所有活动线程,但是该线程在这个时刻,还没有执行到安全点,所以该线程应该继续执行,到达下一个安全点的时候暂停,然后开始GC,该线程等待GC结束。

synchorized加锁位置

  • 对象
    • 对象在内存中分为三块区域(对象头,实例数据,对齐填充)
    • 操作系统层面通过Mutex
  • 方法
    • 操作系统层面通过ACC_SYNCHRONIZED标识
  • 代码块
    • 操作系统层面通过monitorCenter,monitorExit指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

对象头Mark Words

企业微信截图_e839b2ac-a6d6-482a-853d-ec52cce64eb0.png

另外 Monitor 中还有两个队列分别是EntryList和WaitList,主要是用来存放进入及等待获取锁的线程。

synchorized矛盾点

  • A: 重量级锁中的阻塞(挂起线程/恢复线程): 需要转入内核态中完成,有很大的性能影响。

    B: 锁大多数情况都是在很短的时间执行完成。

    解决方案: 引入轻量锁(通过自旋来完成锁竞争)。

  • A: 轻量级锁中的自旋: 占用CPU时间,增加CPU的消耗(因此在多核处理器上优势更明显)。

    B: 如果某锁始终是被长期占用,导致自旋如果没有把握好,白白浪费CPU资源。

    解决方案: JDK5中引入默认自旋次数为10(用户可以通过-XX:PreBlockSpin进行修改), JDK6中更是引入了自适应自旋(简单来说如果自旋成功概率高,就会允许等待更长的时间(如100次自旋),如果失败率很高,那很有可能就不做自旋,直接升级为重量级锁,实际场景中,HotSpot认为最佳时间应该是一个线程上下文切换的时间,而是否自旋以及自旋次数更是与对CPUs的负载、CPUs是否处于节电模式等息息相关的)。

  • A: 无论是轻量级锁还是重量级锁: 在进入与退出时都要通过CAS修改对象头中的Mark Word来进行加锁与释放锁。

    B: 在一些情况下总是同一线程多次获得锁,此时第二次再重新做CAS修改对象头中的Mark Word这样的操作,有些多余。

    解决方案: JDK6引入偏向锁(首次需要通过CAS修改对象头中的Mark Word,之后该线程再进入只需要比较对象头中的Mark Word的Thread ID是否与当前的一致,如果一致说明已经取得锁,就不用再CAS了)。

  • A: 项目中代码块中可能绝大情况下都是多线程访问。

    B: 每次都是先偏向锁然后过渡到轻量锁,而偏向锁能用到的又很少。

    解决方案: 可以使用-XX:-UseBiasedLocking=false禁用偏向锁。

  • A: 代码中JDK原生或其他的工具方法中带有大量的加锁。

    B: 实际过程中,很有可能很多加锁是无效的(如局部变量作为锁,由于每次都是新对象新锁,所以没有意义)。

    解决方法: 引入锁削除(虚拟机即时编译器(JIT)运行时,依据逃逸分析的数据检测到不可能存在竞争的锁,就自动将该锁消除)。

  • A: 为了让锁颗粒度更小,或者原生方法中带有锁,很有可能在一个频繁执行(如循环)中对同一对象加锁。

    B: 由于在频繁的执行中,反复的加锁和解锁,这种频繁的锁竞争带来很大的性能损耗。

    解决方法: 引入锁膨胀(会自动将锁的范围拓展到操作序列(如循环)外, 可以理解为将一些反复的锁合为一个锁放在它们外部)。

    synchorized锁升级

无锁–>偏向锁(等待竞争出现才释放锁的机制)–>轻量级锁–>重量级锁

  • 无锁->偏向锁 [T1 epoch 1 01]
    • 线程1访问同步代码块
    • 检查lock对象的对象头(mark word)中是否存储了线程1
    • 如果是,执行同步代码块
    • 如果不是,则通过CAS替换,设置mark word中的线程ID为T1,替换成功,执行同步代码块,替换不成功,升级锁。
  • 偏向锁->轻量级锁 [空 0 01]->[轻量级锁指针 00]
    • 线程2访问同步代码块
    • 检查lock对象的对象头,发现存储的是线程1,CAS替换失败
    • 等待线程1到达安全点,暂停线程1
    • 检查线程1的状态
      • 如果是未活动状态或者已经退出同步代码块,撤销线程1的偏向锁,重新恢复成无锁状态,唤醒线程2。
      • 如果未退出同步代码块,升级为轻量级锁,在线程1的栈中分配锁记录,拷贝对象头中的mark word到线程1的锁记录中。与此同时线程2的栈也会分配锁记录,拷贝对象头中的mark word到线程2的锁记录中。线程1获得轻量级锁 ([xxx]此时线程2:CAS将对象头的mark word中的锁记录指针指向线程2,如果成功,线程2获得轻量级锁,线程1没有执行完同步代码块的时候,线程2肯定不会成功),唤醒线程1(原从持有偏向锁的线程),从安全点继续执行同步代码块,执行完之后进行轻量级锁的解锁操作。
        • 轻量级锁的解锁操作:1.对象头中的mark word中锁记录指针是否仍指向当前线程锁记录,2.拷贝在当前线程的mark word信息是否与对象头中的mark word一致。1&&2成功,释放锁。1&&2 失败,释放锁,同时唤醒被挂起的线程,开启新一轮锁竞争。
  • 轻量级锁->重量级锁 [ 重量级锁指针 10]
    • 偏向锁升级到轻量级锁的xxx的步骤,线程2,CAS自旋将对象头的mark wor中的锁记录指针指向当前线程2
      • 在一定次数范围内成功的话会获取轻量级锁,执行代码块,执行完之后释放轻量级锁
      • 但是在一定次数仍然没有成功,会升级到重量级锁,mutex挂起当前线程。

企业微信截图_d677a6ad-830f-4cb0-a0a6-501c99bd945c.png

ReentrantLock之AQS

ReentrantLock执行流程.png

  • 核心思想

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

  • 原理实现

    AQS中 维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。

    这里volatile能够保证多线程下的可见性,当state=1则代表当前对象锁已经被占有,其他线程来加锁时则会失败,加锁失败的线程会被放入一个FIFO的等待队列中,比列会被UNSAFE.park()操作挂起,等待其他获取锁的线程释放锁才能够被唤醒。

    另外state的操作都是通过CAS来保证其并发修改的安全性。

  • 定义两种资源共享方式

    • Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:

      • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
      • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
    • Share(共享):多个线程可同时执行,Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock 。
  • 线程1加锁成功,线程2和线程3加锁失败(线程1的state=1和独占线程=线程1,线程2的waitStatus=SINGAL,线程2和线程3加入FIFO的等待队列中)

    企业微信截图_c9bcfb3f-eecf-41d4-ba84-7fa054ef4f6c.png

  • 线程1释放锁,线程2获取锁

    线程1释放锁(state=0,独占线程=null),释放后会唤醒head节点的后置节点也就是线程2

    企业微信截图_2ec8bffa-5f93-460a-8919-f361da5def71.png

    企业微信截图_17b74c9c-b6ae-4801-83b4-1ab53de998b0.png

  • 公平锁

    非公平锁ReentrantLock的默认实现

    • 执行lock()的时候,先尝试用CAS获取一次锁,若获取不到才会进入一个队列等待锁释放

    企业微信截图_6d5fbf4c-6778-4c39-8edf-dc801e7383db.png

    线程二释放锁的时候,唤醒被挂起的线程三线程三执行tryAcquire()方法使用CAS操作来尝试修改state值,如果此时又来了一个线程四也来执行加锁操作,同样会执行tryAcquire()方法。

    这种情况就会出现竞争,线程四如果获取锁成功,线程三仍然需要待在等待队列中被挂起。这就是所谓的非公平锁线程三辛辛苦苦排队等到自己获取锁,却眼巴巴的看到线程四插队获取到了锁。

    公平锁实现

    公平锁在加锁的时候,会先判断AQS等待队列中是存在节点并且当前线程不是锁的持有者,如果符合会直接入队等待

    异同

    非公平锁公平锁的区别:非公平锁性能高于公平锁性能。非公平锁可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量

    非公平锁性能虽然优于公平锁,但是会存在导致线程饥饿的情况。在最坏的情况下,可能存在某个线程一直获取不到锁。不过相比性能而言,饥饿问题可以暂时忽略,这可能就是ReentrantLock默认创建非公平锁的原因之一了。

  • Condition中await和signal

    Condition是在java 1.5中才出现的,它用来替代传统的Objectwait()notify()实现线程间的协作,相比使用Objectwait()notify(),使用Condition中的await()signal()这种方式实现线程间协作更加安全和高效。

    企业微信截图_eee70064-48b7-4120-948f-dba875100738.png

ReentrantLock之应用Condition

  • await的时候,当前线程是有锁的,加入到condition的等待队列中
  • signal的时候,当前线程是有锁的,从等待队列加到aqs的阻塞队列中

至于具体的锁竞争和condition无关,condition只是在操作锁的挂起和唤醒,不操作锁的获取

ReentrantLock底层实现线程调度LockSupport(都是静态方法)(先unpark再park)

挂起和唤醒是线程调度中和锁的实现最密切的操作,juc 中通过一个 LockSupport 来抽象这两种操作,它是创建锁和其它同步类的基础。

  • LockSupport和每个使用它的线程都与一个许可(permit)关联。permit相当于1,0的开关,默认是0,调用一次unpark就加1变成1,调用一次park会消费permit, 也就是将1变成0,同时park立即返回。再次调用park会变成block(因为permit为0了,会阻塞在这里,直到permit变为1), 这时调用unpark会把permit置为1。每个线程都有一个相关的permit, permit最多只有一个,重复调用unpark也不会积累。
  • LockSupport 内部使用 Unsafe 类实现

和Object的wait和notify的异同

(1)wait和notify都是Object中的方法,在调用这两个方法前必须先获得锁对象,但是park不需要获取某个对象的锁就可以锁住线程。

(2)notify只能随机选择一个线程唤醒,无法唤醒指定的线程,unpark却可以唤醒一个指定的线程。

(3)notify先唤醒后await会死锁,LockSupport不会,因为是许可证

Lock[公平]

  • 拿state[volatile],看队列是否需要排队,不需要排队,直接加锁,锁里面的线程改成当前线程。判断是否是当前先线程的重入锁,,不是的化返回false。或者加锁成功
  • 加锁不成功去入队,如果是多个线程并发,并发的线程都需要入队
  • 修改前面node的waitstate状态,之后park[阻塞]

CAS

  • 原子性问题:lock+cmpxchgq 缓存行锁/总线锁
  • ABA
  • 轻量级锁和重量级锁性能(空转耗Cpu。线程多放队列,不耗cpu。重量级锁。)

LongAddr

  • 分段cas,base+cell数组。多线程的化,避免cas空转,操作cell数组,最后获取值的时候把base和cell数组的值加起来。
  • 线程多,自动分配cell数组,线程少了就会减少使用cell数组的个数,如果很少有可能只用base,不用cell数组。

synchorized和ReentrantLock异同

  • 两者都是可重入锁

    两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

  • **synchronized(关键字) 基于objectMonitor 而 ReentrantLock(类) 基于AQS

    synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

  • ReentrantLock 比 synchronized 增加了一些高级功能

    相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)

    • ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
    • ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
    • 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实例中的所有等待线程。
  • synchronized会自动释放锁,而Lock必须手动释放锁。

  • synchronized是不可中断的,Lock可以中断也可以不中断。

  • 通过Lock可以知道线程有没有拿到锁,而synchronized不能。

  • synchronized能锁住方法和代码块,而Lock只能锁住代码块。

  • ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。

  • Lock可以使用读锁提高多线程读效率。

  • 效率比较(看场景)

    • 偏向锁阶段(cas)和lock差不多
    • 轻量级锁阶段lock比较好
    • 重量级锁阶段synchronized比较好,没有那么多的自旋cas

Lock大致加锁流程(非公平就是没事就抢一手试试看能不能拿到锁,或者没事就判断下当前持有锁的线程是不是自己,为了重入的逻辑准备。重入巧妙的处理是超过Integer.MAX+1之后,这个数是复数了)

  • 线程A先执行CAS,将state从0变成1,线程A获取到了资源锁,执行业务代码
  • 线程B执行CAS,发现state已经是1,无法获取资源
  • 线程B去排队,将自己封装成Node对象(waitStatus,next,pre,锁类型,Thread)
  • 需要将线程B的Node放到双向队列保存,排队
    • 但是双向链表需要有一个伪节点作为头节点,并且放到双向队列中
    • 将B线程的Node挂到tail后面,并且将上一个节点的状态改为-1,用LockSupport挂起B线程

Lock.lock()加锁方法(head-Node1-Node2-tail)默认Node应该是waitStatus=0 + thread=null

  • acquire方法
    • tryAcquire:当status=0,CAS获取锁。判断持有锁的线程是不是自己,是自己,重入。都不是往下走acquireQueued。如果是公平锁的逻辑多了一个status=0的时候判断下有没有人排队,如果没有人排队,或者自己是排队的第一个走非公平锁的逻辑。如果别人是第一个排队的,那自己不抢了。
    • acquireQueued(addWaiter())
      • addWaiter:没有拿到锁,封装当前线程为Node,插入AQS,插入方式是CAS方式。(将新Node指向排队的最后一个节点,之后将tail指向新Node,最后之前的最后排队的指向新Node),里面比较巧妙的点是如果CAS放队尾失败(多个线程)或者伪节点释放(会在enq中构建一个空的伪节点),会执行enq方法,里面利用死循环的方式,保证当前线程的Node肯定可以放到AQS的尾部。
      • acquireQueued:查看自己是否是第一个排队的节点,如果是CAS获取锁,如果不是线程挂起。(如果是队列第一个,执行tryAcquire获取锁,如果拿到锁,设置头节点为当前获取锁资源的Node。如果没有资格拿锁资源)
        • 没有拿到锁资源或者没有资格拿到锁资源(不是队列第一位)【死循环】
        • shouldParkAfterFailAcquire:看下线程能否挂起(确认前节点是否具备唤醒当前要挂起线程的条件)
          • 前一个Node的状态waitStatus=-1,当前Node具备挂起条件。
          • 前一个Node的状态waitStatus大于0,那就代表这个Node是取消状态cancel=1,那就循环往上找,找到不大于0的Node节点,最次是头节点,当前Node不具备挂起条件。
          • 前一个Node的状态waitStatus不是0和-1,那就是-2(和Condition相关先不不看),-3(和共享锁相关)和大于0的数,那就代表节点状态正常,当前Node不具备挂起条件。
          • 如果是第三个情况,执行compareAndSetWaitStatus,将找到的上一个节点的状态改为-1
        • 因为acquireQueued中的方法是死循环,死循环里面的逻辑,要不是拿到锁,要不是当前线程具备挂起条件。
        • 如果具备挂起条件,利用LockSupport挂起线程。
        • finally里面的逻辑cancelAcquire走不到,不用看
    • selfInertrupt:中断线程(lock方法不需要考虑中断,tryLock和lockInterrupt方法才需要考虑)

Lock,tryLock() + Lock.tryLock(time,unit)

  • Lock,tryLock()=tryAcquire方法。就拿一次,成功或者失败
  • Lock.tryLock(time,unit):
    • 线程中断标识为true,直接抛异常
    • tryAcquire:公平+非公平,拿锁成功,直接结束,拿锁没有成功,走doAcquireNanos方法
    • doAcquireNanos:
      • 等待时间是0秒,结束
      • 设置结束时间
      • addWaiter,扔AQS里面,之后下面执行一个死循环(拿锁+挂起)
      • 死循环,队列第一个,直接抢锁,抢到结束
      • 死循环,抢不到锁,算下剩余的时间,结束
      • 死循环,shouldParkAfterFailAcquire看能不能挂起,挂起剩余的时间LockSupport.parkNanos。到时间了,自动被唤醒,不需要别的线程唤醒。
      • 如果线程被唤醒了,如果是中断唤醒,那抛异常,走finally中的cancelAcquire方法,如果到时间唤醒,结束。
    • cancelAcquire(线程thread设置为null,waitStatus=1,改变链表指针【让tail指向前面有效的Node】,脱离AQS队列)
      • 脱离AQS队列,当前节点是tail节点,往前找到有效节点(跳过前面的被取消节点,找到有效的)改变指向
      • 脱离AQS队列,当前节点是队列第一个节点,唤醒后面的节点LockSupport.unpark,节点改变指向
      • 脱离AQS队列,当前节点是中间节点,节点改变指向

Lock,lockInterrupt()和 Lock.tryLock(time,unit)类似,没啥说的

Lock释放锁(head,Node1【waitStatus=-1,thread=null】,Node2【waitStatus=-1,thread=B】,Node3【waitStatus=0,thread=C】,tail)

  • Node1是默认的,默认Node应该是waitStatus=0 + thread=null,但是后面有线程,所以waitStatus=-1了。
  • 线程A持有当前锁,state=2,重入了一次
  • 线程B和线程C获取锁资源失败,在AQS中排队
  • 线程A释放锁,调用unlock方法,执行tryRelease方法
  • 判断锁资源是不是被线程A持有,如果不是,抛异常(对使用错误进行了一个校验),结束
  • 锁被线程A持有,对state-1
    • -1成功后,判断state是否为0,如果不是,方法结束
    • 如果为0,证明当前线程锁资源释放完毕了
    • 查看头节点状态,为-1,后续有挂起的节点,找到有效的Node,把state改为0,唤醒

AQS的node状态

  • cancelled=1:当前节点的线程是已取消的
  • signal=-1:当前节点的线程是需要被唤醒的
  • condition=-2:当前节点的线程正在等待某个条件
  • propagate=-3:表示下一个共享模式的节点应该无条件的传播下去

AQS的共享

  • Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。

  • CountDownLatch (倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。

    • 工作步骤

    ​ 初始化时定义几个任务,即同步器中state的数量

    ​ 等待线程执行await方法等待state变成0,等待线程会进入同步器的等待队列

    ​ 任务线程执行countDown方法之后,state值减1,知道减到0,唤醒等待队列中所有的等待线程

    • 同步器 实现了tryAcquireShared方法,判断state!=0的时候把等待线程加入到等待队列并阻塞等待线程,state=0的时候这个latch就不能够再向等待队列添加等待线程;另外实现了tryReleaseShared,判断当前任务是否是最后一个任务,当state减到0的时候就是最后一个任务,然后会以传播唤醒的方式唤醒等待队列中的所有等待线程。
  • CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。Cyc licBarrier默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await()方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

读写锁

AQS只维护了一个state状态变量,ReentrantReadWriteLock利用其高 16 位表示读状态也就是获取该读锁的线程个数,低 16 位表示获取到写锁的线程的可重入次数。



blog comments powered by Disqus