美文网首页
线程同步(锁)

线程同步(锁)

作者: 哓晓的故事 | 来源:发表于2018-08-23 23:21 被阅读0次

    Java的线程安全性问题主要关注点有3个:可见性、有序性和原子性

    • voliate 是由于本身语义禁止了指令重排语义
    • synchronized 加重量锁

    Java内存模型(JMM)解决了可见性和有序性的问题,而锁解决了原子性的问题。

    0. 线程实现

    1. 内核线程实现
      内核态内进行,需要不断的系统调用(用户态<-->内核态切换)
      轻量级线程与内核线程关系 1:1
    2. 用户线程实现(控制过于复杂基本不再使用)
      用户态内进行,不需要切态
      进程与用户线程关系1:N
    3. 混合实现
      轻量级线程与用户线程关系M:N
    4. Java线程实现
      根据操作系统线程模型决定(JVM最新版本使用 轻量级线程LWT实现)

    0.1 Java线程调度

    1. 协同式线程调度(线程自己控制),容易导致一个线程长期block
    2. 抢占式线程调度(系统控制 - Java方式),可以通过线程优先级来控制

    0.2 Java线程状态与操作系统线程状态

    Java线程状态 关系 操作系统线程状态
    New 创建未启动 New
    Runable 执行/等待CPU Running/Ready
    Waiting 不分配CPU,等待其它线程显示唤醒 .
    Timed Waiting 不分配CPU,自动超时/等待其它线程唤醒 .
    Blocked 进入同步区,等待阻塞锁 Blocked
    Teminaled 终止线程 Teminaled
    线程状态转换图.png

    0.3 线程安全实现

    互斥是因,同步是果。互斥是方法,同步是目的

    1. 互斥同步(synchronized/reentranLock)
    2. 非阻塞同步(CAS)
    3. 无同步(可重入代码,线程本地存储 -> 静态方法一般都是可重入)
    • 变量被多线程访问,使用volatile声明“异变”
    • 变量线程独享,使用ThreadLocal

    1. Happens-Before工作内存->主内存交互方式

    1. 需要顺序,但不需要连续,必须成对出现
      1.1 read(主内存读入工作内存) -> load(载入副本)
      1.2 store(工作内存存储到主内存) -> write(写入主内存)
    2. assign(赋值)给工作内存赋值后必须同步回主内存,不允许线程无故同步回主主内存
    3. 初始化一个对象过程 read->load->assign->use->store->write
    4. lock(线程锁定)只能被一个线程操作,但是可以被同一个线程操作多次,同时要执行同样多次的unlock(线程解锁)
    5. unlock/lock 需要成对存在
    6. unlock需要将工作内存同步回主内存(store->write)
    7. lock变量,会清空此变量的工作内存信息(read->load)

    2. synchronized(内置锁)- 监控器monitor实现

    synchronized代码块在Java会被编译成为monitorenter和monitorexit字节码指令(方法常量池method_info结构体里放入标识ACC_SYNCHRONIZED),需要一个reference类型参数来指明锁定和解锁对象

    1. 有重入锁的特性在执行monitorenter指令时,首先要尝试获取对象的锁->进入monitor,如果这个对象没有被锁定(monitor为空),或者当前线程已经拥有了那个对象的锁(重入monitor),把锁的计数器加1,相应地,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就会被释放
    2. 如果获取对象锁失败,那当前线程就要加入同步队列,阻塞等待,直到对象锁被另外一个线程释放,线程争取到信号量
    3. 静态方法同步方式采用的是 Class Lock,非静态方法同步方式采用的是Object Lock
    通过对象引用找到同步对象,然后获取对象上的监视器锁
    - 进入sync 块后, 清空工作内存, 从主内存加载数据
    - 退出 sync 块前, 将工作内存回写主内存
    

    monitorenter和monitorexit 特点是Java内存模型中的lock/unlock更高层的字节码指令:

    1. 同一对象实例 是可重入的,不会锁自身

    2. sync块前后保证有序性(块内部不保证有序性),因此在 DLC(double-checked locking)sync(Objec.class) { o=new Object();} 时代码块内部存在指令重排,对象初始化分为三步:

      a. 为o分配内空间
      b. 执行Object构造函数初始化对象, 在中开辟空间
      c. 栈空间的值存储堆空间的地址

    这时候b和c是可以指令重排,为了保证不被指令重排,应该对o设置为volatile,否则并发的情况下,b还没有执行完就分配了堆地址, 内部数据是错误的.

    1. 可以使用lazy initialization holder解决初始化问题,使用延迟加载,而线程安全使用静态类的加载和初始化保证
    private static class ResourceHolder {
      public static Resource resource = new Resource();
    }
    public static Resource getResource() {
      return ResourceHolder .resource;
    }
    

    尽可能的使用synchronized,易于优化, JVM 保证释放锁
    不足之处:

    1. 不能中断阻塞, 没有timeout机制,不能停止等待,必须到成功
    2. 不能跨越多个对象
    3. 只能是非公平锁

    3. ReentrantLock(显式锁)

    优点:

    1. 等待可中断(等待超时)
    2. 公平/非公平锁
    3. 可以绑定多个condition条件
    4. 可以使用非块结构的锁

    解决写写写读,保证数据一致性的加锁约束,同时也限制了读读

    • 公平锁: 严格按照队列, 等待前续节点
    • 非公平锁: 使用抢占式, 只要state 变为可用, 抢占到即可(头插法变成head)

    state标识状态,AQS在判断状态时,通过用waitStatus>0表示取消状态,而waitStatus<0表示有效状态
    采用前驱设置状态为SIGNAL通知下一个节点pred准备好了的方式,此时后继可以park等待唤醒. 插入使用尾插法

    3.1 acquire(ReentrantLock使用1个信号量来控制lock/unlock)

    1. 先尝试获acquire取信号量
    2. 获取信号量失败,等待队列使用使用尾插法队列,此Node是一个双向链表插入一个waiter node(使用CAS+loop保证线程安全)
    3. 使线程在等待队列一直自旋到获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false(这个自旋只是等待等待队列中被被标记为SIGNAL同步节点park后被unpark,直到所有等待队列中的节点被唤醒并执行)
    4. 获取资源后中出现中断,再进行自我中断selfInterrupt()补偿
    acquire流程.png acquire流程.png
    acquire-release.png

    3.2 公平锁/非公平锁

    使用队列来实现初始化公平

    独占模式:
    acquire 使用队列来在不可获取信号量后,入队列等待。信号量为1的互斥区间

    共享模式:
    acquireShared 信号量为n的的区间,判断是否还有资源,返回-1无资源,否则类似独占模式处理等待队列,在唤醒下一节点的时候有区别独占模式(只有一个资源)只会修改节点,而共享模式有多个资源,在当前节点修改后还有资源,会循环主动唤醒有效的资源waiter节点,直到等待队列中所有的waiter都被唤醒一次,至于唤醒后能不能获取资源由自己去争抢

    3.3 Condition -> await

    reentrantLock->内置有同步队列condition->内置有等待队列

    等待队列(FIFO): 当我们将等待队列中的线程节点加入到同步队列之后,才会唤醒线程。

    3.4 等待队列是单向队列、同步队列是双向队列的一些思考

    同步队列要设计成双向的,是因为在等待队列中,节点唤醒是接力式的,由前一个节点唤醒它的后一个节点,如果是由next指针获取下一个节点,是有可能获取失败的,因为虚拟队列每添加一个节点,是先用CAS把tail设置为新节点,然后才修改原tail的next指针到新节点的。因此用next向后遍历是不安全的,但是如果在设置新节点为tail前,为新节点设置prev,则可以保证从tail往前遍历是安全的。因此要安全的获取一个节点Node的下一个节点,先要看next是不是null,如果是null,还要从tail往前遍历看看能不能遍历到Node

    等待队列单向,等待的线程就是等待者,只负责等待,唤醒的线程就是唤醒者,只负责唤醒,因此每次要执行唤醒操作的时候,直接唤醒同步队列的首节点就行了。等待队列的实现中不需要遍历队列,因此也不需要prev指针

    4. volatile

    volatile在Java会被编译成0x01a3de24:lock addl $0x0,(%esp) ;...f0830424 00指令, 来指示缓存一致原则

    此指令相当于一个内存屏障,在多CPU访问同一块内存屏障(Memory Barrier)的时候,有 总线锁/缓存锁 两种方式来保证只有一个处理器可以修改, 在修改后缓存上的数据后写回内存, 通过缓存一致性原则, 让其他处理器上的数据缓存无效. 强制读取内存来达到 可见性. 而 barrier 的存在禁止了对其进行指令重拍.
    不要做 volatile int count=0; count++;操作,这个不保证原子性

    • 保障可见性/有序性
    • 不保障原子性

    使用场景:

    1. 写入变量不依赖变量当前值
    2. 不与其他状态变量共同参与
    3. 访问变量不需要加其他锁

    读有monitorenter语义
    写有monitorexit语义

    5. (关卡barrier)CyclicBarrier

    CyclicBarrier(集结点)设置集中总数. 当一个线程运行barrier.await()时, 如果集中点没有足够多线程达到, 会等待,直到所有的线程都到达了这个点,所有线程才重新运行
    CyclicBarrier重用,使用reset()

    6. (闭锁latch)CountDownLatch / FuntureTask

    CountDownLatch(计数器)某线程运行到某个点上之后,只是给某个数值减1而已,该线程继续运行
    CountDownLatch.await() 可以有多个线程监听, 不可重用,无法重置

    FutureTask 多线程处理,在 run() 的过程中,线程使用get(),会一致阻塞到获得run()的结果
    闭锁不阻塞线程本身,等待的是事件

    7. AtomicXXX 原子变量

    CAS (Compareand-Swap) 原子操作
    这是由硬件提供原子操作指令实现的

    CAS采用自旋等待的方式来解决同步问题,绝大多数的并发都是瞬时的,等待片刻即可
    阻塞的,需要挂起线程,稍后需要唤醒进程,大量的中断等待
    CAS在非激烈竞争的情况下,开销更小,速度更
    CAS在激烈竞争的情况下,和锁的性能类似或者更

    java.util.concurrent中实现的原子操作类包括:AtomicBoolean、AtomicInteger、AtomicLong、AtomicReference

    Lock-Free算法(乐观锁):Lock-Free 是指能够确保执行它的所有线程中至少有一个能够继续往下执行。由于每个线程不是 starvation-free 的,即有些线程可能会被任意地延迟,然而在每一步都至少有一个线程能够往下执行,因此系统作为一个整体是在持续执行的,可以认为是 system-wide的。所有 Wait-free 的算法都是 Lock-Free 的。
    Mutex操作系统实现,而atomic包中的原子操作则由底层硬件直接提供支持。在 CPU 实现的指令集里,有一些指令被封装进了atomic包,这些指令在执行的过程中是不允许中断(interrupt)的,因此原子操作可以在lock-free的情况下保证并发安全,并且它的性能也能做到随 CPU 个数的增多而线性扩展

    8. 原子性

    一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为原子性(atomicity), CPU 不可能不中断的执行一系列操作,但如果我们在执行多个操作时,能让他们的中间状态对外不可见,那我们就可以宣称他们拥有了”不可分割”的原子性下(我怎么觉得这个更像一致性)

    相关文章

      网友评论

          本文标题:线程同步(锁)

          本文链接:https://www.haomeiwen.com/subject/nsqjrftx.html