Java并发编程阅读博客笔记

211 阅读16分钟
## Java并发编程阅读博客笔记

一:缘由

  • 优点

      	充分利用多核CPU的计算能力;
      	方便进行业务拆分,提升应用性能;
    
  • 缺点

      	 频繁的上下文切换:【 存储和恢复CPU状态的过程,它使得线程执行能够从中断点恢复执行】通常减少上下文切换可以采用无锁并发编程[减少一部分不必要的锁竞争带来的上下文切换],CAS算法,使用最少的线程和使用协程[单线程里实现多任务的调度]

    ​ 线程安全[死锁,同步],避免死锁的情况:

    1. 避免一个线程同时获得多个锁;

    2. 避免一个线程在锁内部占有多个资源,尽量保证每个锁只占用一个资源;

    3. 尝试使用定时锁。

  • 概念

    ​ 并发、并行 临界区 同步、异步

二、线程状态转换

  • 创建线程

    通过继承Thread类,重写run方法;

    通过实现runable接口;

    通过实现callable接口这三种方式

  • 线程状态转换

  • 线程状态的基本操作【线程间一种通信方式】

    1. 中断:表示一个运行中的线程是否被其他线程进行了中断操作。boolean interrupted() :检测当前线程是否被中断。与isInterrupted不同的是,该方法发现当前线程被中断后会清除中断标志。

      一个线程在未正常结束之前, 被强制终止是很危险的事情. 因为它可能带来完全预料不到的严重后果比如会带着自己所持有的锁而永远的休眠,迟迟不归还锁等。 所以你看到Thread.suspend, Thread.stop等方法都被Deprecated了

    一个比较优雅而安全的做法是:使用等待/通知机制或者给那个线程一个中断信号, 让它自己决定该怎么办。

    1. 中断对于等待获取锁对象的synchronized方法或者代码块并不起作用,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不生效。

      • interrupt是中断处于阻塞/睡眠状态的线程,退出阻塞状态继续运行。没有占用CPU运行的线程是不可能给自己的中断状态置位的。这就会产生一个InterruptedException异常。

      • 中断正在运行的线程设置中断标志。被中断线程可以决定如何应对中断
    2. join方法可以看做是线程间协作的一种方式,很多时候,一个线程的输入可能非常依赖于另一个线程的输出。

    3. sleep()方法只是会让出CPU并不会释放掉对象锁;wait()方法必须要在同步方法或者同步块中调用,也就是必须已经获得对象锁。

    4. yield()方法只允许与当前线程具有相同优先级的线程能够获得释放出来的CPU时间片。调用yield方法并不会让线程进入阻塞状态,而是让线程重回就绪状态。不能控制具体的交出CPU的时间。


三、JMM与happens-before

  • 线程安全:当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。

  • 出现线程安全的问题一般是因为主内存和工作内存数据不一致性重排序

  • 多线程条件下,多个线程肯定会相互协作完成一件事情,一般来说就会涉及到多个线程间相互通信告知彼此的状态以及当前的执行结果

  • 线程协作的方式:1)共享变量 2)等待通知 3)中断

  • 并发编程中主要需要解决两个问题:1. 线程之间如何通信【共享内存和消息传递】;2.线程之间如何完成同步【控制不同线程间操作发生的相对顺序】

  • JMM决定了一个线程对共享变量的写入何时对其他线程是可见的。

  • 为了提高性能,编译器和处理器常常会对指令进行重排序 【不存在数据依赖性】 在多线程状态下可能会导致问题

  • happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证

  • 从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。【happens-before关系并不代表了最终的执行顺序。只是逻辑顺序。如果结果一致允许重排序】。

  • 上层会有基于JMM的关键字和J.U.C包下的一些具体类用来方便程序员能够迅速高效率的进行并发编程

    总结:1. JMM的抽象结构(主内存和线程工作内存);2. 重排序以及happens-before规则。

四、synchronized简介

  • 任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

  • 同一锁程中,线程不需要再次获取同一把锁。Synchronized先天具有重入性。

  • JMM核心为两个部分:happens-before规则以及内存抽象模型

  • 释放锁的时候会将值刷新到主内存中,其他线程获取锁时会强制从主内存中获取最新的值。即临近区的值永远最新。

    CAS
  • CAS操作(又称为无锁操作)是一种乐观锁策略【增强效率】,它假设所有线程访问共享资源的时候不会出现冲突。冲突就重试。

  • CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。

  • Synchronized(未优化前)问题是:在存在线程竞争的情况下会线程阻塞和唤醒锁带来的性能问题,(阻塞同步)。当CAS操作失败后会进行一定的尝试,叫做非阻塞同步。

  • 1. ABA问题 2. 自旋时间过长 3. 只能保证一个共享变量的原子操作【atomic中提供了AtomicReference来保证引用对象之间的原子性。】

    Java对象头
  • 同步的时候是获取对象的monitor,即获取到对象的锁。那么对象的锁怎么理解?无非就是类似对对象的一个标志,那么这个标志就是存放在Java对象的对象头

  • Mark Word【32位】会默认存放hasdcode,年龄值以及锁标志位等信息。

  • 锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态

  • 当一个线程访问同步块并获取锁时,会在对象头栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁【未开启偏向锁】;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。【竞争偏向锁】

  • 偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

  • 线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

  • 轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

    五、volatile

  • 针对volatile修饰的变量给java虚拟机特殊的约定,线程对volatile变量的修改会立刻被其他线程所感知,即不会出现数据脏读的现象,从而保证数据的“可见性”

  • 将当前处理器缓存行的数据写回系统内存,这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效。【缓存一致性

    三大性质

    有序性:双重检验锁定:分配地址(volatile写)和初始化(普通写)乱序导致判断if(instance==null)时就会为true。【此时可见性是前提】

    如果在本线程内观察,所有的操作都是有序的;如果在一个线程观察另一个线程,所有的操作都是无序的

    为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

    对于有数据依赖的指令不会重排序,这在单线程环境中是安全的。但是在多线程中,无数据依赖的指令重排序依旧会产生问题。

    JMM实现java正确的多线程编程对底层无数据依赖的指令重排序提供了上层约束(关键字和lock);

    cas保证了不会覆盖,volatile保证可见性。【避免脏读】

    volatile保证原子性,必须符合以下两条规则:

    1. 运算结果并不依赖于变量的当前值,或者能够确保只有一个线程修改变量的值;
    2. 变量不需要与其他的状态变量共同参与不变约束

    volatile和CAS不一样,volatile实现可见性是通过强制刷新主存和缓存一致性协议。CAS是一个乐观锁,会消耗CPU资源,拿来对synchronize进行优化【锁的升级】


六.lock体系

  • synchronized同步块执行完成或者遇到异常是锁会自动释放,而lock必须调用unlock()方法释放锁,因此在finally块中释放锁
  • 并发包这些类的实现主要是依赖于volatile以及CAS。
  • AQS子类被推荐定义为自定义同步组件的静态内部类,步器既支持独占式获取同步状态,也可以支持共享式获取同步状态,这样就可以方便的实现不同类型的同步组件。
  • 锁是面向使用者,它定义了使用者与锁交互的接口,隐藏了实现细节;同步器是面向锁的实现者,它简化了锁的实现方式,屏蔽了同步状态的管理,线程的排队,等待和唤醒等底层操作

AQS:多线程访问共享资源的同步器框架

  • AQS只是一个框架,具体资源的获取/释放方式交由自定义同步器去实现

    自定义同步器实现时主要实现以下几种方法:[等待队列的维护\同步状态的循环获取已经设计好了]
    
    isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
    tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
    tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
    tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
    tryReleaseShared(int):共享方式。尝试释放资源,成功则返回true,失败则返回false
  • 同步器数据结构

    • 节点的数据结构,即AQS的静态内部类Node,节点的等待状态等信息;【双链表】
    • 同步队列是一个双向队列,AQS通过持有头尾指针管理同步队列
    • 通过一个volatile int 记录同步状态。
  • 取锁失败进行入队操作,获取锁成功进行出队操作。【AQS框架已经设计好了】

Acquire方法逻辑
tryAcquire()尝试直接去获取资源,如果成功则直接返回;
addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
acquireQueued()使线程在等待队列中获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。【CAS自旋(解决了原子性问题,失败则重试)volatile变量】
如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
  • 获取独占式锁失败的线程包装成Node然后插入同步队列的过程

    • 在当前线程是第一个加入同步队列时,调用compareAndSetHead(new Node())方法,完成链式队列的头结点的初始化;【带头结点的链式存储结构
    • 自旋不断尝试CAS尾插入节点直至成功为止
  • lock支持在获取锁的过程中响应中断(在其他线程锁释放之后,尝试获取锁的过程中检查有中断立即抛出中断异常,停止获取锁,节点状态变为取消状态)

  • acquireInterruptibly立即响应【已经消除了中断标识,线程状态并标记为Cancel状态】
    acquire方法是设置中断标识供获取锁之后处理
    
       private final boolean parkAndCheckInterrupt() {
            LockSupport.park(this);
            //判断当前线程是否中断,且清除标志。
            return Thread.interrupted();
        }
        
    if (shouldParkAfterFailedAcquire(p, node) &&
                        parkAndCheckInterrupt())
                        throw new InterruptedException();
                }
    
    finally {
                //线程响应中断处理
                if (failed)
                    cancelAcquire(node);
            }
    

    共享模式与独占模式

    公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序,而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象 【可能有上下文切换】

    Condition

    Object的wait和notify/notify是与对象监视器配合完成线程间的等待/通知机制,而Condition与Lock配合完成等待通知机制,前者是java底层级别的,后者是语言级别的,具有更高的可控制性和扩展性

    ConditionObject该类是AQS(AQS的实现原理的文章)的一个内部类

    当当前线程调用condition.await()方法后,会使得当前线程释放lock然后加入到等待队列中,直至被signal/signalAll后会使得当前线程从等待队列中移至到同步队列中去,直到获得了lock后才会从await方法返回,或者在等待时被中断会做中断处理

    ConcurrentHashmap

    利用了锁分段的思想提高了并发度

    原子类

    getAndIncrement() 方法并不是原子操作。 只是保证了他和其他函数对 value 值得更新都是有效的

    CAS 的问题1.ABA 问题2.自旋时间过长【非阻塞同步】

    阻塞队列(BlockingQueue)是一个支持以下两个附加操作的队列:

    • 支持阻塞的插入方法:当队列满时,队列会阻塞插入元素的线程,直到队列不满。

    • 支持阻塞的移除方法:在队列为空时,获取元素的线程会等待队列变为非空。

    Executor框架

    • 工作单元
      • Runnable
      • Callable
    • 执行机制
      • Executor框架
  • 两级调度模型

    HotSpot VM的线程模型中,Java线程(java.lang.Thread`)被 一对一映射为本地操作系统线程

    Executor框架)将这些任务映射为固定数量的线程

    execute()方法用于提交不需要返回值的任务

    submit()方法用于提交需要返回值的任务

    • 通常调用shutdown方法来关闭线程池。
    • 如果任务不一定要执行完,则可以调用shutdownNow方法。
  • 在工作线程的run方法中循环执行阻塞队列中的任务。

    其他

    阻塞与等待的区别

    阻塞:当一个线程试图获取对象锁(非java.util.concurrent库中的锁,即synchronized),而该锁被其他线程持有,则该线程进入阻塞状态。由JVM调度器来决定唤醒自己,而不需要由另一个线程来显式唤醒自己,不响应中断**。 等待:当一个线程等待另一个线程通知调度器一个条件时,该线程进入等待状态。它的特点是需要等待另一个线程显式地唤醒自己,实现灵活,语义更丰富,可响应中断。例如调用:Object.wait()、Thread.join()以及等待Lock或Condition。

      需要强调的是虽然synchronized和JUC里的Lock都实现锁的功能,但线程进入的状态是不一样的。synchronized会让线程进入阻塞态,而JUC里的Lock是用LockSupport.park()/unpark()[同步队列和等待队列依赖其实现]来实现阻塞/唤醒的,会让线程进入等待态。但话又说回来,虽然等锁时进入的状态不一样,但被唤醒后又都进入runnable态,从行为效果来看又是一样的。

参考文档:

  1. java并发图谱
  2. 并发系列文章
  3. AQS详解 4.zhuanlan.zhihu.com/p/86853461