并发编程

以Java语言来进行描述

进程,线程,协程
进程基础
线程基础
什么是线程安全和线程不安全
关于ThreadLocal

ThreadLocal的作用是提供线程内的局部变量,就是在各线程内部创建一个变量的副本,相比于使用各种锁机制访问变量,ThreadLocal的思想就是用空间换时间,使各线程都能访问属于自己这一份的变量副本,变量值不互相干扰,减少同一个线程内的多个函数或者组件之间一些公共变量传递的复杂度。

  1. ThreadLocalMap的结构

    static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
    }
    
  2. 每个Thread维护一个ThreadLocalMap,存储在ThreadLocalMap内的就是一个以Entry为元素的table数组,Entry就是一个key-value结构,key为ThreadLocal,value为存储的值。类比HashMap的实现,其实就是每个线程借助于一个哈希表,存储线程独立的值。

  3. 因为Entry是继承了WeakReference实现的,当ThreadLocal Ref销毁时,指向堆中ThreadLocal实例的唯一一条强引用消失了,只有Entry有一条指向ThreadLocal实例的弱引用,根据弱引用的特性,那么这里ThreadLocal实例是可以被GC掉的。这时Entry里的key为null了,那么直到线程结束前,Entry中的value都是无法回收的,这里可能产生内存泄露,所以我们要记住手动清除,需要手动调用remove函数,删除不再使用的ThreadLocal.

  4. HashMap采用拉链法处理哈希冲突,即在一个位置已经有元素了,就采用链表把冲突的元素链接在该元素后面,而ThreadLocal采用的是开放地址法,即有冲突后,把要插入的元素放在要插入的位置后面为null的地方

  5. 在每一个get,set操作时都会不断清理掉key为null的Entry的。

  6. 如果size大于3/4的threshold,则调用resize函数

  7. 每次扩容大小扩展为原来的2倍,然后再一个for循环里,清除空key的Entry,同时重新计算key不为空的Entry的hash值,把它们放到正确的位置上,再更新ThreadLocalMap的所有属性。

关于synchronized

加锁过程: 如果 monitor 的进入数为0,则该线程进入 monitor,然后将进入数设置为1,该线程即为 monitor 的所有者; 如果线程已经占有monitor,只是重新进入,则monitor的进入数+1; 如果其他线程已经占用 monitor,则该线程处于阻塞状态,直至 monitor 的进入数为0,再重新尝试获得 monitor 的所有权

释放锁过程: monitorexit:执行 monitorexit 的线程必须是 objectref 所对应的 monitor 的所有者。执行指令时,monitor 的进入数减1,如果减1后进入数为0,则线程退出 monitor,不再是这个 monitor 的所有者,其他被这个 monitor 阻塞的线程可以尝试获取这个 monitor 的所有权。

  1. 确保线程互斥的访问代码块,同一时刻只有一个方法可以进入到临界区
  2. monitorenter 指令在编译为字节码后插入到同步代码块的开始位置,monitorexit 指令在编译为字节码后插入到方法结束处和异常处。JVM 要保证每个 monitorenter 必须有对应的 moniorexit。
  3. monitorenter:每个对象都有一个监视器锁(monitor),当 monitor 被某个线程占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获得 monitor 的所有权,即尝试获取对象的锁。过程如下:
  4. ObjectMonitor 的几个核心属性:
ObjectMonitor() {
	...
    _count        = 0;      //记录个数
    _owner        = NULL;   //持有monitor的线程
    _WaitSet      = NULL;   //处于wait状态的线程,会被加入到_WaitSet
    _EntryList    = NULL ;  //处于等待锁block状态的线程,会被加入到该列表
    ...
  }
synchronized的优化

无锁–>轻量级锁–>偏向锁–>重量级锁

锁随着竞争情况可以升级,但锁升级后不能降级,意味着不能从轻量级锁状态降级为偏向锁状态,也不能从重量级锁状态降级为轻量级锁状态

自旋锁和适应自旋:这个需要确认是否自旋了?

  • JVM中对象的内存布局

关于Java对象的内存布局:对象头,实际数据,对齐填充(让CPU尽可能用少的读取次数来读取到有用的数据):https://www.jianshu.com/p/91e398d5d17c

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header),实例数据( Instance Data)和对齐填充(Padding)。

实例数据:存储着对象真正的有效信息(程序代码中定义的各种类型的字段内容),无论是从父类继承来的字段还是子类中定义的;

对齐填充:【未开启指针压缩】Java对象占用空间是8字节对齐的,即所有Java对象占用bytes数必须是8的倍数。例如,一个包含两个属性的对象:int和byte,这个对象需要占用8+4+1=13个字节,这时就需要加上大小为3字节的padding进行8字节对齐,最终占用大小为16个字节;

  • 这里我们主要看一下对象头。Java对象头很重要,synchronize、GC、HashCode、biasedLock、ObjectMonitor都是在对象头上做文章。

第一,Mark Word:存储对象自身的运行时数据,如:Hash Code,GC 分代年龄、锁信息。这部分数据在32位和64位的 JVM 中分别为 32bit 和 64bit。考虑空间效率,Mark Word 被设计为非固定的数据结构,以便在极小的空间内存储尽量多的信息。

第二,存储指向方法区对象类型数据的指针。

第三,如果是数组对象的话,额外会存储数组的长度。

JVM一般是这样使用锁和Mark Word的:

1,当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。

3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。

4,当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。

5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存了对象所mark word的复本,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。

6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。

7,自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

关于JUC

Hello JUC

关于AQS

参考:https://javadoop.com/post/AbstractQueuedSynchronizer

在并发环境下,加锁和解锁需要以下三个部件的协调:

  1. 锁状态。我们要知道锁是不是被别的线程占有了,这个就是 state 的作用,它为 0 的时候代表没有线程占有锁,可以去争抢这个锁,用 CAS 将 state 设为 1,如果 CAS 成功,说明抢到了锁,这样其他线程就抢不到了,如果锁重入的话,state进行 +1 就可以,解锁就是减 1,直到 state 又变为 0,代表释放锁,所以 lock() 和 unlock() 必须要配对啊。然后唤醒等待队列中的第一个线程,让其来占有锁。
  2. 线程的阻塞和解除阻塞。AQS 中采用了 LockSupport.park(thread) 来挂起线程,用 unpark 来唤醒线程。
  3. 阻塞队列。因为争抢锁的线程可能很多,但是只能有一个线程拿到锁,其他的线程都必须等待,这个时候就需要一个 queue 来管理这些线程,AQS 用的是一个 FIFO 的队列,就是一个链表,每个 node 都持有后继节点的引用。AQS 采用了 CLH 锁的变体来实现。
关于CAS

Compare And Swap的缩写,即比较并交换,其作用是让CPU比较内存中某个值是否和预期的值相同,如果相同则将这个值更新为新值,不相同则不做更新,也就是CAS是原子性的操作(读和写两者同时具有原子性),其实现方式是通过借助C/C++调用CPU指令完成的,所以效率很高。

  1. 通过Unsafe(sun.misc.Unsafe)类实现CAS的操作,我们知道Java是无法直接访问操作系统底层的API的(原因是Java的跨平台性限制了Java不能和操作系统耦合),所以Java并没有在Unsafe类直接实现CAS的操作,而是通过**JDI(Java Native Interface)**本地调用C/C++语言来实现CAS操作的。

  2. Unsafe.java作用:

    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

    • 将对象引用、值在对象中的偏移量、期望的值和欲更新的新值传递给Unsafe.cpp

    • 如果值更新成功则返回true给开发者,没有更新则返回false

  3. Unsafe.cpp作用:

    接受从Unsafe传递过来的对象引用、偏移量、期望的值和欲更新的新值,根据对象引用和偏移量计算出值的地址,然后将值的地址、期望的值、欲更新的新值传递给CPU,如果值更新成功则返回true给Unsafe.java,没有更新则返回false;

  4. CPU作用: 接受从Unsafe.cpp传递过来的地址、期望的值和欲更新的新值,执行指令cmpxchg,比较地址中的值是否和期望的值一样,一样则将值更新为新的值,不一样则不做任何操作,将操作结果返回给Unsafe.cpp

并发工具

在JUC中中通过底层的AQS,ReentrantLock,Condition,LockSupport,各种Queue来封装好了上层工具来供开发者使用,这里简单介绍一下。

  1. CountDownLatch

CountDownLatch是一个同步工具类,它允许一个或多个线程一直等待,直到其他线程的操作执行完后再执行; CountDownLatch是通过一个计数器来实现的,当我们在new一个CountDownLatch对象的时候需要带入该计数器值,该值就表示了线程的数量。每当一个线程完成自己的任务后,计数器的值就会减1。当计数器的值变为0时,就表示所有的线程均已经完成了任务,然后就可以恢复等待的线程继续执行了。

//AQS 里面的 state 是一个整数值,这边用一个 int count 参数其实初始化就是设置了这个值,所有调用了 await 方法的等待线程会挂起,然后有其他一些线程会做 state = state - 1 操作,当 state 减到 0 的同时,那个将 state 减为 0 的线程会负责唤醒 所有调用了 await 方法的线程
public CountDownLatch(int count) {
    if (count < 0) throw new IllegalArgumentException("count < 0");
    this.sync = new Sync(count);
}
// 内部封装一个 Sync 类继承自 AQS
private static final class Sync extends AbstractQueuedSynchronizer {
    Sync(int count) {
        // 这样就 state == count 了
        setState(count);
    }
    ...
}
  1. Semaphore

需要一个参数 permits,这个基本上可以确定是设置给 AQS 的 state 的,然后每个线程调用 acquire 的时候,执行 state = state - 1,release 的时候执行 state = state + 1,当然,acquire 的时候,如果 state = 0,说明没有资源了,需要等待其他线程 release。

在构造函数中, 传入你需要维护的有限资源的数量, 每次需要申请资源时, 调用acquire(), 接着做一些业务逻辑, 完成后, 再调用release()归还有限资源. 如果有限资源被申请完了后, 还调用acquire()的话, 调用的线程会被阻塞, 直到其他线程归还资源后, 才从阻塞中返回。

  1. CyclicBarrier

    ReentrantLock 和 Condition 的组合使用

    CyclicBarrier允许一组线程互相等待其到达一个同步点的时候才继续执行. 同时CyclicBarrier具有重用性, 当等待的一组线程被释放后(成功或者失败), 它能够被继续使用. 这点和CountDownLatch不一样, CountDownLatch只能够使用一次。

    CyclicBarrier遵循all-or-none的损坏模式, 相互等待的一组线程如果其中一个线程被中断的或者等待超时的话, 其他线程也将失败. 简单来讲, 要么全部成功, 要么全部失败.

  2. ReentrantLock

  3. Condition

  4. FutureTask

  5. ReentrantReadWriteLock

    在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。

    在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

    所以,一个线程要想同时持有写锁和读锁,必须先获取写锁再获取读锁;写锁可以“降级”为读锁;读锁不能“升级”为写锁。

    读写锁对于同步状态的实现是在一个整形变量上通过“按位切割使用”:将变量切割成两部分,高16位表示读,低16位表示写。

    假设当前同步状态值为S,get和set的操作如下:

    (1)获取写状态:

    S&0x0000FFFF:将高16位全部抹去

    (2)获取读状态:

    S»>16:无符号补0,右移16位

    (3)写状态加1:

    S+1

    (4)读状态加1:

      S+(1«16)即S + 0x00010000

    // 计数器
    static final class HoldCounter {
        // 计数
        int count = 0;
        // Use id, not reference, to avoid garbage retention
        // 获取当前线程的TID属性的值
        final long tid = getThreadId(Thread.currentThread());
    }
    
    // 本地线程计数器
    static final class ThreadLocalHoldCounter
        extends ThreadLocal<HoldCounter> {
        // 重写初始化方法,在没有进行set的情况下,获取的都是该HoldCounter值
        public HoldCounter initialValue() {
            return new HoldCounter();
        }
    }
    

一些问题

公平锁和非公平锁

公平锁就是保障了多线程下各线程获取锁的顺序,先到的线程优先获取锁,而非公平锁则无法提供这个保障。

  1. 公平锁,就是在获取锁之前会先判断等待队列是否为空或者自己是否位于队列头部,该条件通过才能继续获取锁。
  2. 非公平锁,若释放锁的时候,正好一个线程来竞争锁,而此时位于队列头的线程还没有被唤醒(因为线程上下文切换是需要不少开销的),此时后来的线程则优先获得锁,成功打破公平,成为非公平锁;对于非公平锁,只要线程进入了等待队列,队列里面依然是FIFO的原则,跟公平锁的顺序是一样的

上文说到的线程切换的开销,其实就是非公平锁效率高于公平锁的原因,因为非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销。

Callable和Runnable的区别
  1. 返回值
  2. 异常处理
好玩的
十年生死两茫茫,不思量,自难忘。--[江城子·乙卯正月二十日夜记梦]
相关介绍
参考

> 可在下面留言(需要有 GitHub 账号)