[多线程] 并发编程-总

438 阅读17分钟

本文部分内容摘自:

  1. 大白话AQS

  2. 深入分析AQS实现原理

  3. AQS基本原理 源码解析很清楚

0、Synchronized

Synchronized锁升级过程中,锁的是谁,对象吗?

  • synchronized(this) 或 非静态同步方法:锁是当前实例对象

  • synchronized(Xxx.class) 或 静态同步方法:锁是类的Class对象

是的,锁的是对象,更具体地说,锁的是对象在堆内存中的对象头(Object Header)里的Mark Word。

  • Mark Word:是对象头的一部分,是JVM层面每个对象都天生自带的数据结构。它存在于对象内存布局的最开始部分,用于存储对象自身的运行时数据。

  • Synchronized:是Java语言层面的一个关键字,它利用并修改了对象头(特别是Mark Word)来实现锁功能。

锁升级的过程,本质就是根据竞争情况,在Mark Word中不断更换“锁标记”和“状态位”的过程.

无锁状态,偏向锁,轻量级锁,重量级锁

偏向锁:第一个获得锁的线程,线程1进来,将线程id加入到锁对象的对象头中,标志位改为1,当其他线程来的时候,就会立即结束这个偏向状态。进入轻量级状态

轻量级锁:是在低并发情况下,来消除锁的源于,他主要是在虚拟机占中开辟一个空间叫做lock record,将锁对象的Markword 写入,然后尝试将另一个lock record的指针,使用cas去修改锁对象头的那个区域,完成加锁过程

重量级锁:锁竞争激烈立即膨胀成重量级锁,那用的是互斥锁的过程:同步方法/同步代码块

锁升级过程:

1、当JVM启动后,一个共享资源对象直到有线程第一个访问时,这段时间内是处于无锁状态,对象头的Markword里偏向锁标识位是0,锁标识位是01。

2、当一个共享资源首次被某个线程访问时,锁就会从无锁状态升级到偏向锁状态,偏向锁会在Markword的偏向线程ID里存储当前线程的操作系统线程ID,偏向锁标识位是1,锁标识位是01。另外需要注意的是,由于硬件资源的不断升级,获取锁的成本随之下降,jdk15版本后默认关闭了偏向锁。

3、轻量级锁是在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,尝试拷贝锁对象头的Markword到栈帧的Lock Record,若拷贝成功,JVM将使用CAS操作尝试将对象头的Markword更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象头的Markword。

若拷贝失败,若当前只有一个等待线程,则可通过自旋继续尝试, 当自旋超过一定的次数,或者一个线程在持有锁,一个线程在自旋,又有第三个线程来访问时,轻量级锁就会膨胀为重量级锁。

4、当轻量级锁获取锁失败时,说明有竞争存在,轻量级锁会升级为重量级锁;底层是通过操作系统的mutex lock来实现的,每个对象指向一个monitor对象。

一、threadlocal

 threadlocal,在实际项目中应用:比如:保存本次请求用户的信息、logId或者一些需要在一次request时需要都用的数据

二、线程的生命周期与状态流转

参考:

Java并发编程系列:线程的五大状态,以及线程之间的通信与协作

Java 线程的 5 种状态

线程的 6 种状态

  1. NEW(新建)

  2. RUNNABLE(可运行)

  3. BLOCKED(阻塞)

  4. WAITING(等待)

  5. TIMED_WAITING(超时等待)

  6. TERMINATED(终止)

三、线程的通信与协作:

sleep、wait、notify、yield、join关系与区别:参考

四、线程同步与锁

1、队列同步器AQS

AQS的内部实现

  • AQS的实现依赖内部的同步队列,也就是FIFO的双向队列,如果当前线程竞争锁失败,那么AQS会把当前线程以及等待状态信息构造成一个Node加入到同步队列中,同时再阻塞该线程。当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。

  • AQS队列内部维护的是一个FIFO的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开始很方便的访问前驱和后继。每个Node其实是由线程封装,当线程争抢锁失败后会封装成Node加入到AQS队列中去

AQS的两种功能

从使用层面来说,AQS的功能分为两种:独占和共享

  • 独占锁,每次只能有一个线程持有锁,比如前面给大家演示的ReentrantLock就是以独占方式实现的互斥锁

  • 共享锁,允许多个线程同时获取锁,并发访问共享资源,比如ReentrantReadWriteLock

相关方法属性

public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer{
    //指向同步队列队头
    private transient volatile Node head;

    //指向同步的队尾
    private transient volatile Node tail;

   //同步状态,0代表锁未被占用,1代表锁已被占用
    private volatile int state;
}

总结:如上图所示为AQS的同步队列模型;

AQS内部有一个同步队列,它是由Node组成的双向链表结构
AQS内部通过state来控制同步状态,当执行lock时,如果state=0时,说明没有任何线程占有共享资源的锁,此时线程会获取到锁并把state设置为1;当state=1时,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待.

AQS内部分为共享模式(如Semaphore)和独占模式(如Reentrantlock),无论是共享模式还是独占模式的实现类,都维持着一个虚拟的同步队列,当请求锁的线程超过现有模式的限制时,会将线程包装成Node结点并将线程当前必要的信息存储到node结点中,然后加入同步队列等会获取锁,而这系列操作都有AQS协助我们完成,这也是作为基础组件的原因,无论是Semaphore还是Reentrantlock,其内部绝大多数方法都是间接调用AQS完成的。

2、释放锁以及添加线程对于队列的变化

添加节点

当出现锁竞争以及释放锁的时候,AQS同步队列中的节点会发生变化,首先看一下添加节点的场景。里会涉及到两个变化

  • 新的线程封装成Node节点追加到同步队列中,设置prev节点以及修改当前节点的前置节点的next节点指向自己

  • 通过CAS讲tail重新指向新的尾部节点

释放锁移除节点

head节点表示获取锁成功的节点,当头结点在释放同步状态时,会唤醒后继节点,如果后继节点获得锁成功,会把自己设置为头结点,节点的变化过程如下

这个过程也是涉及到两个变化

  • 修改head节点指向下一个获得锁的节点

  • 新的获得锁的节点,将prev的指针指向null

这里有一个小的变化,就是设置head节点不需要用CAS,原因是设置head节点是由获得锁的线程来完成的,而同步锁只能由一个线程获得,所以不需要CAS保证,只需要把head节点设置为原首节点的后继节点,并且断开原head节点的next引用即可

3、公平锁与非公平锁

ReentrantLock.lock()public void lock() {    
   sync.lock();
}

这个是获取锁的入口,调用sync这个类里面的方法,sync是什么呢?

sync是一个静态内部类,它继承了AQS这个抽象类,前面说过AQS是一个同步工具,主要用来实现同步控制。我们在利用这个工具的时候,会继承它来实现同步控制功能。 通过进一步分析,发现Sync这个类有两个具体的实现,分别是 NofairSync(非公平锁), FailSync(公平锁).

  • 公平锁 表示所有线程严格按照FIFO来获取锁

  • 非公平锁 表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他线程等待,新线程都有机会抢占锁

面试题:公平锁与非公平锁是通过什么实现的?CAS

// 公平锁(FairSync)
protected final boolean tryAcquire(int acquires) {
    if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
        setExclusiveOwnerThread(Thread.currentThread());
        return true;
    }
    return false;
}

// 非公平锁(NonfairSync)
final boolean nonfairTryAcquire(int acquires) {
    if (compareAndSetState(0, acquires)) { // 直接CAS抢锁
        setExclusiveOwnerThread(Thread.currentThread());
        return true;
    }
    return false;
}
  • 公平锁

原则‌:严格遵循FIFO队列顺序,先请求的线程优先获取锁。
实现关键‌:tryAcquire()方法中调用hasQueuedPredecessors()检查队列中是否有更早的等待线程,若有则阻塞当前线程。
优点‌:避免线程饥饿。
缺点‌:上下文切换频繁,吞吐量较低。

  • 非公平锁

原则‌:允许线程插队,新请求的线程可直接尝试抢锁,无需检查队列顺序。
实现关键‌:lock()方法直接CAS抢锁(compareAndSetState),失败后再加入队列。
优点‌:减少线程切换,吞吐量高。
缺点‌:可能导致线程长期饥饿

4、重入锁加锁释放锁的步骤

ReentrantLock内部包含了一个AQS对象,也就是AbstractQueuedSynchronizer类型的对象。这个AQS对象就是ReentrantLock可以实现加锁和释放锁的关键性的核心组件。

AQS内部有个变量state,是int类型的,代表了加锁的状态。初始状态下,state值是0。
AQS内部还有个变量,用来记录当前加锁的是哪个线程,初始化状态下,变量是null。
AQS内部还有一个等待队列,专门放那些加锁 败的线程!

  • 1、线程1跑过来调用ReentrantLock的lock()方法尝试进行加锁,这个加锁的过程,直接就是用CAS操作将state值从0变为1。一旦线程1加锁成功了之后,就可以设置当前加锁线程是自己。

如何进行可重入加锁!其实每次线程1可重入加锁一次,会判断一下当前加锁线程就是自己,那么他自己就可以可重入多次加锁,每次加锁就是把state的值给累加1,别的没变化

  • 2、线程2跑过来一下看到,state的值不是0啊?所以CAS操作将state从0变为1的过程会失败,因为state的值当前为1,说明已经有人加锁了!接着线程2会看一下,是不是自己之前加的锁啊?当然不是了,**“加锁线程”**这个变量明确记录了是线程1占用了这个锁,所以线程2此时就是加锁失败。

  • 3、接着,线程2会将自己放入AQS中的一个等待队列,因为自己尝试加锁失败了,此时就要将自己放入队列中来等待,等待线程1释放锁之后,自己就可以重新尝试加锁了

AQS是如此的核心!AQS内部还有一个等待队列,专门放那些加锁失败的线程!

  • 4、接着,线程1在执行完自己的业务逻辑代码之后,就会释放锁!他释放锁的过程非常的简单,就是将AQS内的state变量的值递减1,如果state值为0,则彻底释放锁,会将“加锁线程”变量也设置为null!

  • 5、接下来,会从**等待队列的队头唤醒线程2重新尝试加锁。线程2现在就重新尝试加锁,这时还是用CAS操作将state从0变为1,此时会成功,成功之后代表加锁成功,就会将state设置为1。此外,还要把“加锁线程”**设置为线程2自己,同时线程2自己就从等待队列中出队了。

5、Synchronized 和 ReentrantLock 区别

本题转自 :公众号来源:孤独烟

API方面: synchronized既可以修饰方法,也可以修饰代码块。ReentrantLock只能在方法体中使用。
公平锁: synchronized的锁是非公平锁,ReentrantLock默认情况下也是非公平锁,但可以通过带布尔值的构造函数要求使用公平锁。
等待可中断: 假如业务代码中有两个线程,Thread1 Thread2。假设 Thread1 获取了对象object的锁,Thread2将等待Thread1释放object的锁。

  • 使用synchronized。如果Thread1不释放,Thread2将一直等待,不能被中断。synchronized也可以说是Java提供的原子性内置锁机制。内部锁扮演了互斥锁(mutual exclusion lock ,mutex)的角色,一个线程引用锁的时候,别的线程阻塞等待。

  • 使用ReentrantLock。如果Thread1不释放,Thread2等待了很长时间以后,可以中断等待,转而去做别的事情。

至于判断重入锁, ReenTrantLock的字面意思就是再进入的锁,其实synchronized关键字所使用的锁也是可重入的,两者关于这个的区别不大。两者都是同一个线程没进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

6、重入锁如果不支持重入会怎样?

 核心问题:线程死锁‌‌

自身阻塞:
当线程已持有锁后,‌再次尝试获取同一把锁时会被自身阻塞‌(因锁已被当前线程占用)。
此时线程既无法继续执行,也无法释放锁,形成永久阻塞‌

 实际影响

程序永久僵死
线程因自我阻塞无法推进,且锁永不释放,相关代码段完全瘫痪‌

系统级故障
若发生在关键线程(如服务主线程),会导致整个应用无响应‌

五、并发包

扩展:hashset,hashtable

0. hashset:无序、不重复

HashSet底层使用了哈希表来支持的,特点:存储快

哈希表的出现是为了解决链表访问不快速的弱点,哈希表也称散列表。

HashSet是通过HasMap来实现的,HashMap的输入参数有Key、Value两个组成,在实现HashSet时,保持HashMap的Value为常量,相当于在HashMap中只对Key对象进行处理。

HashSet存储对象的过程

往HashSet添加元素的时候,HashSet会先调用元素的hashCode方法得到元素的哈希值 ,

然后通过元素的哈希值经过移位等运算,就可以算出该元素在哈希表中的存储位置。

情况1: 如果算出元素存储的位置目前没有任何元素存储,那该元素可以直接存储到该位置上

情况2: 如果算出该元素的存储位置目前已经存在有其他的元素了,那么会调用该元素的equals方法与该位置的元素再比较一次,如果equals返回的是true,那么该元素与这个位置上的元素就视为重复元素,不允许添加,如果equals方法返回的是false,那么该元素运行添加。

1. hashmap

所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。

说到这里,我们再回头看一下hashmap中默认的数组大小是多少,查看源代码可以得知是16,为什么是16,而不是15,也不是20呢,看到上面的解释之后我们就清楚了吧,显然是因为16是2的整数次幂的原因,在小数据量的情况下16比15和20更能减少key之间的碰撞,而加快查询的效率。 

2. concurrentMap

摘自:JDK1.8中的实现

ConcurrentHashMap取消了segment分段锁,而采用CAS和synchronized来保证并发安全。数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树

synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。TreeBin: 红黑二叉树节点,Node: 链表节点。

  1. 判断Node[]数组是否初始化,没有则进行初始化操作
  2. 通过hash定位Node[]数组的索引坐标,是否有Node节点,如果没有则使用CAS进行添加(链表的头结点),添加失败则进入下次循环。
  3. 检查到内部正在扩容,如果正在扩容,就帮助它一块扩容。
  4.  如果f!=null,则使用synchronized锁住f元素(链表/红黑二叉树的头元素):         f:链表或红黑二叉树头结点,向链表中添加元素时,需要synchronized获取f的锁。
    4.1 如果是Node(链表结构)则执行链表的添加操作。
    4.2 如果是TreeNode(树型结果)则执行树添加操作。
  5. 判断链表长度已经达到临界值8 就需要把链表转换为树结构。

总结:
JDK8中的实现也是锁分离的思想,它把锁分的比segment更细一些,只要hash不冲突,就不会出现并发获得锁的情况。它首先使用无锁操作CAS插入头结点,如果插入失败,说明已经有别的线程插入头结点了,再次循环进行操作。如果头结点已经存在,则通过synchronized获得头结点锁,进行后续的操作。性能比segment分段锁又再次提升。

3. ConcurrentLinkedQueue

参见:Java并发包--ConcurrentLinkedQueue , 

         Java并发编程之ConcurrentLinkedQueue详解

ConcurrentLinkedQueue是一个基于链接节点的无界线程安全队列,它采用FIFO先进先出的规则对节点进行排序,当我们添加一个元素的时候,它会添加到队列的尾部,当我们获取一个元素时,它会返回队列头部的元素。

ConcurrentLinkedQueue的数据结构,如下图所示:

说明
1. ConcurrentLinkedQueue继承于AbstractQueue。
2. ConcurrentLinkedQueue内部是通过链表来实现的。它同时包含链表的头节点head和尾节点tail。ConcurrentLinkedQueue按照 FIFO(先进先出)原则对元素进行排序。元素都是从尾部插入到链表,从头部开始返回。
3. ConcurrentLinkedQueue的链表Node中的next的类型是volatile,而且链表数据item的类型也是volatile。关于volatile,我们知道它的语义包含:“即对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入”。ConcurrentLinkedQueue就是通过volatile来实现多线程对竞争资源的互斥访问的。

4. 阻塞队列

摘自:阻塞队列及实现原理

三种阻塞队列:
BlockingQueue workQueue = null;
workQueue = new ArrayBlockingQueue<>(5);   //基于数组的先进先出队列,有界
workQueue = new LinkedBlockingQueue<>();   //基于链表的先进先出队列,无界
workQueue = new SynchronousQueue<>();      //无缓冲的等待队列,无界

1、ArrayBlockingQueue是一个用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原则对元素进行排序。默认情况下不保证访问者公平的访问队列,

访问者的公平性是使用可重入锁实现的,代码如下:

public ArrayBlockingQueue(int capacity, boolean fair) {
        if (capacity <= 0)
            throw new IllegalArgumentException();
        this.items = new Object[capacity];
        lock = new ReentrantLock(fair);
        notEmpty = lock.newCondition();
        notFull =  lock.newCondition();
}

2、LinkedBlockingQueue是一个用链表实现的有界阻塞队列。此队列的默认和最大长度为Integer.MAX_VALUE。此队列按照先进先出的原则对元素进行排序。

3、SynchronousQueue是一个不存储元素的阻塞队列。每一个put操作必须等待一个take操作,否则不能继续添加元素。SynchronousQueue可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。队列本身并不存储任何元素,非常适合于传递性场景,比如在一个线程中使用的数据,传递给另外一个线程使用,SynchronousQueue的吞吐量高于LinkedBlockingQueue 和 ArrayBlockingQueue。

总结:

六. 并发工具类 

CountDownLatch、Semaphore和CyclicBarrier

参考:Java并发之CountDownLatch、Semaphore和CyclicBarrier

          CountDownLatch使用之等待超时   

CountDownLatch:
.await(long, TimeUnit); 等待超时,针对某些业务场景,如果某一个线程的操作耗时非常长或者发生了异常. 但是并不想影响主线程的继续执行, 则可以使用await(long, TimeUnit)方法. 即一个线程(或者多个线程),等待另外n个线程执行long时间后继续执行. 

七. 原子操作类

摘自:AtomicInteger

synchronized :重量级操作,基于悲观锁,可重入锁。

AtomicInteger:乐观 ,用CAS实现

incrementAndGet()方法在一个无限循环体内,不断尝试将一个比当前值大1的新值赋给自己,如果失败则说明在执行"获取-设置"操作的时已经被其它线程修改过了,于是便再次进入循环下一次操作,直到成功为止。

CAS指令在Intel CPU上称为CMPXCHG指令,它的作用是将指定内存地址的内容与所给的某个值相比,如果相等,则将其内容替换为指令中提供的新值,如果不相等,则更新失败。这一比较并交换的操作是原子的,不可以被中断。初一看,CAS也包含了读取、比较 (这也是种操作)和写入这三个操作,和之前的i++并没有太大区别,是的,的确在操作上没有区别,但CAS是通过硬件命令保证了原子性,而i++没有,且硬件级别的原子性比i++这样高级语言的软件级别的运行速度要快地多。虽然CAS也包含了多个操作,但其的运算是固定的(就是个比较),这样的锁定性能开销很小。

通过查看AtomicInteger的源码可知, 

private volatile int value;

public final boolean compareAndSet(int expect, int update) {

return unsafe.compareAndSwapInt(this, valueOffset, expect, update);

}

通过申明一个volatile (内存锁定,同一时刻只有一个线程可以修改内存值)类型的变量,再加上unsafe.compareAndSwapInt的方法,来保证实现线程同步的。

优点:AtomicInteger比直接使用传统的java锁机制(阻塞的)有什么好处?最大的好处就是可以避免多线程的优先级倒置死锁情况的发生,当然高并发下的性能提升也是很重要的。

比较:

  • 低并发情况下:使用AtomicInteger,因为其是基于乐观锁,并发低,基本都能成功。

  • 高并发情况下:使用synchronized,如果此时使用AtomicInteger,失败的概率很大,incrementAndGet()就需要一直不断重复的尝试,直到成功。既然很大情况会失败,就直接synchronized锁住

八、线程池相关

摘自:Java-五种线程池,四种拒绝策略,三种阻塞队列

1、线程池要隔离

摘自:构建更健壮的系统:不同的业务放在不同的线程/线程池里面

2、拒绝策略

3、阻塞队列 原理:AtomicInteger

4、如何合理设置线程池大小

对于不同性质的任务来说,

  • CPU密集型任务应配置尽可能小的线程,如配置CPU个数+1的线程数,

  • IO密集型任务应配置尽可能多的线程,因为IO操作不占用CPU,不要让CPU闲下来,应加大线程数量,如配置两倍CPU个数+1,

  • 而对于混合型的任务,如果可以拆分,拆分成IO密集型和CPU密集型分别处理,前提是两者运行的时间是差不多的,如果处理时间相差很大,则没必要拆分了。

九、线程安全的程序计数器

线程安全的计数器实现原理简介:
在java中volatile关键字可以保证共享数据的可见性,它会把更新后的数据从工作内存刷新进共享内存,并使其他线程中工作内存中的数据失效,进而从主存中读入最新值来保证共享数据的可见性,实现线程安全的计数器通过循环CAS操作来实现。就是先获取一个旧期望值值,再比较获取的值与主存中的值是否一致,一致的话就更新,不一致的话接着循环,直到成功为止.

程序参考:java如何实现线程安全的计数器

Java 提供了一组atomic class来帮助我们简化同步处理。基本工作原理是使用了同步synchronized的方法实现了对一个long, integer, 对象的增、减、赋值(更新)操作. 

程序参考:Java线程安全的计数器  ;