Java并发编程知识点总结

369 阅读8分钟

大纲

之前总结的一版

并发基本总结

CPU和内存关系

JVM内存模型

写入时复制

写入时复制(CopyOnWrite,简称COW)思想是计算机程序设计领域中的一种通用优化策略。其核心思想是,如果有多个调用者(Callers)同时访问相同的资源(如内存或者是磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者修改资源内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的(transparently)。此做法主要的优点是如果调用者没有修改资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。

通俗易懂的讲,写入时复制技术就是不同进程在访问同一资源的时候,只有更新操作,才会去复制一份新的数据并更新替换,否则都是访问同一个资源。

JDK 的 CopyOnWriteArrayList/CopyOnWriteArraySet 容器正是采用了 COW 思想,它是如何工作的呢?简单来说,就是平时查询的时候,都不需要加锁,随便访问,只有在更新的时候,才会从原来的数据复制一个副本出来,然后修改这个副本,最后把原数据替换成当前的副本。修改操作的同时,读操作不会被阻塞,而是继续读取旧的数据。这点要跟读写锁区分一下。

JVM的8种原子操作

这个8个原子操作是JVM实现内存屏障的基本

线程的状态示意图

线程的状态

阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态,但是阻塞在java. concurrent包中Lock接口的线程状态却是等待状态,因为java.concurrent包中Lock接口对于阻塞的实现均使用了LockSupport类中的相关方法。

等待通知

等待通知

等待通知

wait()方法会释放锁。

AQS

可重写方法

模版方法

原理

  • Step1:
    public final void acquire(int arg) {
          if (!tryAcquire(arg) &&
              acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
              selfInterrupt();
      }
    
  1. 首先调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态。
  2. 如果同步状态获取失败,则构造同步节点(独占式Node. EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)方法将该节点加入到同步队列的尾部。
  3. 最后调用acquireQueued(Node node, int arg)方法,使得该节点以“死循环”的方式获取同步状态。
  • Step2:

    private Node addWaiter(Node mode) {
          Node node = new Node(Thread.currentThread(), mode);
          // Try the fast path of enq; backup to full enq on failure
          Node pred = tail;
          if (pred != null) {
              node.prev = pred;
              if (compareAndSetTail(pred, node)) {
                  pred.next = node;
                  return node;
              }
          }
          enq(node);
          return node;
      }
    
    private Node enq(final Node node) {
         for (;;) {
             Node t = tail;
             if (t == null) { // Must initialize
                 if (compareAndSetHead(new Node()))
                     tail = head;
             } else {
                 node.prev = t;
                 if (compareAndSetTail(t, node)) {
                     t.next = node;
                     return t;
                 }
             }
         }
     }
    
    1. 先使用CAS尝试快速添加尾结点。
    2. 如果失败说明同步队列没有被初始化或者有其他线程设置了尾结点。如果没有初始化头节点,则创建一个compareAndSetHead(Node update)。(这个头节点的Thread为空),然后自旋使用CAS设置尾结点。
  • Step3:

    final boolean acquireQueued(final Node node, int arg) {
         boolean failed = true;
         try {
             boolean interrupted = false;
             for (;;) {
                 final Node p = node.predecessor();
                 if (p == head && tryAcquire(arg)) {
                     setHead(node);
                     p.next = null; // help GC
                     failed = false;
                     return interrupted;
                 }
                 if (shouldParkAfterFailedAcquire(p, node) &&
                     parkAndCheckInterrupt())
                     interrupted = true;
             }
         } finally {
             if (failed)
                 cancelAcquire(node);
         }
     }
    

    只有前驱是头节点的结点才能“死循环”获取同步状态。这样是为了保证同步队列的FIFO原则。最后通过sendHead(Node node)设置头结点,应为只有一个结点能获取到同步状态,所以不存在并发安全问题。

获取锁的过程

  • 总结:

    • 在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;
    • 移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。

ReentrantLock

实现原理

ReentrantLock类通过组合一个同步组件Sync来实现锁,这个同步组件继承了AQS,并重写了AQS中的tryAcquire()、tryRelease()等方法,最终实际上还是通过AQS以及重写的tryAcquire()、tryRelease()等方法来实现锁的逻辑。ReentrantLock实现锁的方式,也是JUC包下其他类型的锁的实现方法,通过组合一个自定义的同步组件,这个同步组件需要继承AQS,然后重写AQS中的部分方法即可实现一把自定义的锁,通常这个同步组件被定义成内部类。

公平和非公平锁实现

由FairSync同步组件实现的锁是公平锁,它获取锁的原则是,在同步队列中等待时间最长的线程获取锁,因此称它为公平锁。由NonfairSync同步组件实现的锁是非公平锁,它获取锁的原则是,同步队列外的线程在尝试获取锁时,不会判断队列中有没有线程在排队,而是上来就抢,抢到锁了就走,抢不到了才去排队,因此称它为不公平的。

ReadWrite实现原理

利用AQS的state的同步状态,高16位表示读锁,低16位表示写锁。只能支持锁降级,不能锁升级。

锁升级指的是线程获取到了读锁,在没有释放读锁的前提下,又获取写锁。锁降级指的是线程获取到了写锁,在没有释放写锁的情况下,又获取读锁。

StampedLock

提供乐观读的方式,在读的同时允许写。ReadWriteReentrantLock是悲观锁。 StampedLock支持锁升级。

缺点:

  • 不支持重入。
  • 不支持条件变量。
  • 中断会导致CPU暴涨。

CountDownLatch原理

CountDownLatch利用的是AQS的是共享锁。CountDownLatch在构造方法中指定了计数器的初始值,即AQS中的同步变量state的值。当调用await()方法时,会判断此时state的值是否为0,如果为0,就让当前线程返回,如果不为0,就让当前线程进入同步队列等待。在同一时刻它允许多个线程来访问state,每当调用一次countDown()方法的时候,让state的值减1,当state值为0的时候释放同步队列的结点。

CyclicBarrier原理

CyclicBarrier和CoutDownLatch的底层实现也存在一点区别,CountDownLatch底层是直接通过组合一个继承了AQS的同步组件来实现的,而CyclicBarrier并没有直接借助AQS的同步组件,而是通过组合ReentrantLock这把锁来实现的(ReentrantLock的底层实现依然使用的AQS来实现的,归根结底,CyclicBarrier的底层实现也是AQS)。 由于CyclicBarrier是使用ReentrantLock来实现的,因此它有个属性是lock。在CyclicBarrier中还维护了一个计数器:count。由于CyclicBarrier可以重复使用,即计数器减为0后,将其重置,因此还需要借助另外一个变量来存放count的初始值,这个变量就是parties。CyclicBarrier中有个属性是generation,其类型是一个CyclicBarrier的内部类Generation,它的作用是用来实现await(long timeout,TimeUnit unit)方法的超时等待的功能。

CyclicBarrier与CountDownLacth存在几点区别。

  • 首先CyclicBarrier是让所有线程到达屏障后再一起执行后面的逻辑,而CountDownLatch是让一个线程或者一组线程等待其他线程执行完后,自己再接着执行。
  • 第二,CyclicBarrier的计数器可以重置,因此可以重复使用,而CountDownLatch的计数器不能重置,不可以重复使用。
  • 第三,CyclicBarrier可以在所有线程达到屏障后,先执行一个Runnable任务,然后才打开屏障,这个功能在特殊场景下很有用处。第四,虽然两者最终底层实现都是根据AQS来实现的,但是CyclicBarrier是通过ReentrantLock这个互斥锁来间接使用AQS实现的,而CountDownLatch是直接使用AQS的共享锁来实现的。

作者:天堂同志 链接:juejin.cn/post/684490…

线程池的原理

作者:天堂同志 链接:juejin.cn/post/684490…

简单来说线程池是通过一个int类型来保存线程池的状态和线程的数量的,高3位表示状态、低29位表示线程数量。执行任务的是Worker,Worker中有一个属性是thread。创建Worker的时候需要使用全局锁,这也是为什么要先添加队列,后创建线程的原因。

如何关闭线程池

juejin.cn/post/684490…

如何处理线程池的异常

juejin.cn/post/684490…

如何计算线程数量

  • CPU密集型

    对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。

  • IO密集型

    最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]