梁恒嘉的技术专栏 全栈之路

Java中的并发编程

2022-10-20
NanKe

记录Java并发编程的知识,包括并发编程的详细介绍,并发编程解决的问题,volatile关键字,各种锁机制,synchronized的底层原理,CAS机制,AQS机制,以及JUC里面常见方法

一、什么是并发编程

在计算机里面有并发与并行的概念;当我们完成一件事情的时候,我们一定对完成这件事情的时间与效率很关心;当我们使用计算机去执行命令帮助我们完成一件事情也是一样的,我们希望计算机高效的完成;根据冯诺依曼计算机模型我们可以知道计算机大体组成是:运算器,控制器,存储器,输入设备,输出设备。计算机发展过程中CPU承担了运算器与控制器的作用,内存与外存承担了存储器的功能,我们现在需要从CPU与内存,磁盘存储(外存)三者进行分析:CPU现在性能强劲,计算速度很快;内存相对于外存来讲,速度是快的,但是与CPU运算速度进行比较的话就慢了;为了缓解这三者之间的速度差异,我们使用了缓存;从外存获取必要的数据资源信息到内存,CPU从内存中获取必要的数据资源;从多核CPU进行分析,每个CPU都有属于自己的工作内存,所有这些CPU又共享一块工作内存,每个CPU是不可以访问其他CPU的工作内存的,当他们将数据在直接的工作内存中处理完毕就会将数据写入到所有CPU共享的工作内存,最后写入到我们的外存中;这个过程让CPU减少了访问外存的次数,提高了执行效率;但是也带来了问题:多个CPU同时刻分别执行任务,这就是并行的概念;单个CPU在一个时间段内分别执行任务就是并发的概念;并行是提高执行速率的一个关键,也是带来问题的关键

在单个CPU执行任务的时候会出现并发的现象,也就意味着CPU有可能不会等一个线程执行完毕就让出执行权,这种情况就是出现了非原子性;因为每个CPU都有只属于自己的一块工作内存,其他CPU无法访问,在某种情况下就会出现数据不一致;如:现在有两个CPU都从共享的工作内存空间读取到num=0,此时CPU1将num数值改为1,此时CPU2也将num数值改为1,现在两个CPU都需要把num的值写会到内存中,但是CPU1先进行写入内存操作,之后CPU2才执行,此时的实际结果是num的值是1;但是我们需要的结果是num=2;这与我们期望结果不一致;这也就是导致的第二个问题,不可见性;由于CPU执行的时候会将指令的顺序进行重新排序(编译优化),所以CPU实际执行的指令顺序与我们的代码顺序可能是不一致的,但是有一些业务逻辑我们是不希望让这种不一致出现的;这也就导致了第三个问题,非有序性

我们的并发编程就是围绕着解决这三个问题去展开的

从计算数据结果去分析,其实是因为:在解决CPU,内存,磁盘之间读写速度差异大,提高计算机效率的时候,引入了缓存,线程,并发,并行的思想之后带来了新问题,不管是非原子性,不可见性,还是非有序性,它们三者最终导致的问题都是程序对内存中数据操作结果达不到预期目标


二、volatile关键字

共享变量被volatile修饰后,保证不同线程对这个变量的操作互相之间可以看见;保证修饰的变量编译优化的时候不会被重排序;volatile保证了有序性和可见性,但是无法保证原子性

三、解决非原子性

可以通过原子变量与加锁的方式去解决非原子性的操作

1.原子变量

对于count++ 这种操作来说,在计算机底层实现的时候,一共分了三步,第一步从主内存读取操作数据到工作内存,第二步操作工作内存,对操作数据进行改变,第三步将操作完成的数据写回到主内存。这三步实现的任何一个时刻都有可能会发生并发,或者阻塞,让其不具备原子性(一个操作是不可打破的,它的所有操作是一个整体操作,缺一不可,它一旦执行后,不应该被打破);此时我们需要解决这个非原子性问题;使用synchronized 成本太高了,需要先获取锁,最后需要释放锁,获取不到锁的情况下需要等待,还会有线程的上下文切换,这些操作成本太大,不值得

对于以上这种情况,完全可以使用原子变量代替

在java.util.current包里面包含一些帮助我们实现原子性的工具类如:

AtomicBoolean: 原子Boolean 类型,常用来程序中表示一个标志位

AtomicInteger: 原子Integer 类型

这些原子变量的底层是通过volatile关键字和CAS(compare and swap)机制实现的;比较并交换

CAS机制:比较并交换机制;主要思想是一个线程从内存中拿到了数据,这个数据我们记作内存值V,我们对这个V进行操作之后,得到一个更新值B,此时我们线程需要把自己工作内存中的这个更新值B写入到主内存中,此时会再次从主内存获取这个内存值,此时从主内存获取到的值记作期望值A,我们比较期望值A与内存值V,如果我们发现A==V,则说明我们可以操作,现在将更新值B写入到主内存中;如果发现A与V不相等,我们则会进行循环操作,重新去读取内存值,操作数据得到更新值,然后去比较内存值与期望值,直到我们成功将更新值写入到内存

从描述中我们可以看出来,CAS不适合并发量大场景,因为这种循环是很浪费空间的,一旦大量进程都进入循环,那计算机资源消耗还是很大的

ABA问题:CAS机制只是对比了期望值与内存值的数值是否一致,这个样子不严谨,如主内存中这个值是1,线程1读入1,更改为2;此时线程1阻塞,线程2读入1,更改为2,也写入到主内存,成功将主内存值改为2;线程3进入,读入2,更改为1,也写入到主内存,成功将主内存值改回1;此时线程1得到执行权,它去比较发现可以执行,会将2写入到主内存。但是实际情况我们内存的这个值是被更改,最后又改回去的;CAS算法对这种情况并不可以检测到

解决方法是,我们加入版本号,每对值进行一次更改操作,我们就将版本更新,最后在比较期望值与内存值的基础上加入比较版本号

2.锁

不全是真正的锁,有的是锁思想,锁特性,锁设计,有的是锁状态

乐观锁: 乐观的认为,不需要去使用锁可以实现并发安全,使用CAS思想去不断尝试

悲观锁: 每次访问数据的时候,悲观的认为数据都有可能出现不一致,使用锁机制对并发进行处理,使每次都只会有一个进程访问数据

可重入锁: 也称之为递归锁,比如synchronized和ReentrantLock都是可重入锁,有效避免了死锁;当一个线程进入外层方法获取锁,内层调用了另一个需要获取锁的方法是可以进入的

读写锁: 这是一种真正的锁,维护了两个锁,一个是读锁,一个是写锁;读取数据的时候,允许多个进程进入;写入数据的时候,只允许一个线程进入

分段锁: 这是一种锁的思想,对代码进行分段加锁,一次就可以让多个线程进入;锁的粒度更小,提高并发

自旋锁: 采用CAS思想;不断地尝试获取锁,如果没有锁则进入,有锁则继续尝试获取锁,不会让线程阻塞,但是消耗CPU资源;适合用于加锁时间短的场景

共享锁: 如读锁;这个锁可以被多个线程获取

独占锁: 也叫互斥锁,比如synchronized和ReentrantLock都是独占锁,一次只允许让一个线程进入

公平锁: 按照请求锁的顺序,去分配锁;如ReentrantLock可以通过AQS将其改为公平锁

非公平锁: 不按照请求顺序去分配锁;比如synchronized和ReentrantLock都是非公平锁

synchronized中,对锁进行了优化,提出了有四种锁状态,去优化锁的释放与获取效率:

  1. 无锁状态: 该对象没有锁
  2. 偏向锁: 如果这个对象加了锁的,并且只有一个线程访问这个对象,此时会将线程ID存入到对象头中,下次这个线程再次访问的时候就会立马分配锁给这个线程
  3. 轻量级锁: 当其他线程也访问这个对象,则将这个锁升级为轻量级锁;不会让线程进入阻塞状态,通过自旋的思想去获取锁,提高效率
  4. 重量级锁: 线程访问数量过多,线程自旋数量达到一定数量,锁升级为重量级锁,线程进入阻塞状态,让操作系统进行线程调度

JVM为了提高锁的释放与获取效率,专门为synchronized设计的锁状态

3.对象头

实例对象包括:

  1. 对象头: 包括记录GC分代年龄,偏向线程ID,线程持有的锁,锁状态标志等
  2. 实例数据 : 就是对象的属性数据
  3. 对象对齐填充: 虚拟机自动内存管理系统要求对象起始地址必须是8字节的整数倍;换句话说就是任何对象的大小都必须是8字节的整数倍;对象头已经被精心设计为8字节的1倍或者2倍,当我们的实例数据的大小不是8字节的整数倍的时候,就需要使用对象对齐填充部分补齐

4.synchronized的底层实现

synchronized是Java中的关键字,可以修饰代码块与方法;一次只允许一个线程进入,当有一个线程进入后,在对象头里面的锁标志就会+1(默认为0),当线程执行完毕,锁标志就会-1

Java提供了原子性内置锁,Java每个对象都可以作为监视器对象;Java的synchronized实现就是基于进入和退出监视器对象实现的;如:被synchronized修饰的方法执行后,会被ACC_SYNCHRONIZED标记,之后会被监视器对象监视,也就是monitorenter,此时其他线程就不会进入,当同步方法执行完毕,monitorexit退出监视器对象

5.AQS

AbstractQueuedSynchronized,抽象的同步队列;使用AQS可以高效构建锁和同步器的框架;是JUC中核心的API

实现原理: 在内部存在一个用volatile修饰的state变量,初始化为0,在多线程条件下,当有一个线程进入共享资源后,会将这个state值+1,此时共享资源成了临界资源,当有其他线程访问共享资源的时候,发现state状态不为0,则会到一个FIFO队列中等待,当state的值为0的时候,会从这个等待队列中让下一个节点对象获取state。另外state是原子变量,使用volatile修饰,保证它的有序性和可见性,并且使用原子操作方法,保证线程安全;这个等待队列是双向链表实现的,head节点代表当前占用的线程,其他节点依次是由于暂时获取不到锁,依次排队等待的线程;队列由Node对象组成,Node是AQS中的内部类trant

6.ReentrantLock

可重入锁,ReentrantLock类里面包含三个内部类(NonfairSync,FairSync,Sync)

ReentrantLock默认是非公平锁,可以通过构造器或者AQS去将其改变为公平锁

NonFairSync和FairSync两者都继承Sync的内部类;Sync继承了AbstractQueuedSynchronizer

四、常用JUC类

1.ConcurrentHashMap

这是Java中提供的支持高并发的哈希表

JDK5的时候添加ConcurrentHashMap内部使用分段锁让线程安全

到了JDK8之后,放弃了用分段锁,因为对于哈希表来讲,加入分段锁浪费空间

并且线程在哈希表中争抢同一个锁的概率十分小

分段锁反而让更新操作长时间等待

JDK8之后是在Node上使用CAS和synchronized的方式保证原子性

当添加元素到哈希表时会判断存储数据的节点是否为null,如果是null,则使用CAS机制直接添加数据到节点

如果判断结果不是null,则将第一个节点作为锁对象,实现加锁,阻止其他线程访问

2.CopyOnWriteArrayList

这是Java中提供支持高并发的单列数据容器

它支持读数据可以多线进行,写数据的时候只能单线程进行

在CopyOnWriteArrayList内部写入数据的时候先将原数据进行拷贝,得到副本,将修改后的数据写入到副本中,最后再用数据副本替换原来的数据

这样子实现了读数据与写数据互不影响

3.CountDownLatch

如果此时我们需要在线程1与线程2执行完毕之后再执行线程3

那么此时可以使用CountDownLatch

4.CyclicBarrier

有若干个线程,比如说有五个线程,需要它们都到达了某一个点之后才能开始一起执行,也就是说假如其中只有四个线程到达了这个点,还差一个线程没到达,此时这四个线程都会进入等待状态,直到第五个线程也到达了这个点之后,这五个线程才开始一起进行执行状态

当没有可选的Runnable时

当所有线程到达屏障时,不需要进行汇总,最后一个线程到达时,屏障消除,所有线程继续执行

当有可选的Runnable时

当所有线程到达屏障时,需要进行汇总操作,等汇总操作进行完,屏障消除,所有线程继续执行


上一篇 JVM垃圾回收

下一篇 Java线程池

 

Content