并发的特性和锁的原理,分类

并发的特性和锁的原理,分类

前言

每一个开发人员都需要学习并理解并发编程与多线程,这是技术提升道路上必须会的技能,只有掌握了并发编程才拥有了继续提升技术的基础。并发编程实质上就是指对同一资源的竞争下,如何正确合理的使用资源。要想合理的竞争使用同一资源,那么就必须要保障有顺序的去使用同一资源。锁是其中的一种手段,通过加锁的方式强制保证线程一个一个按照顺序来访问资源。那么本篇我们就讲讲并发编程的特性和锁的原理和分类。

并发编程的三个概念

1.原子性

原子性:一个命令或者多个命令,要么全部执行成功并且执行是连续的,也不会被其他命令打断,要么就都不执行。

最经典的例子就是银行转钱,从A账户转1000到B账户,一定要保证A账户扣钱成功,B账户加钱成功。如果这两个命令不具有原子性,那么可能造成钱凭空多了1000或者少了1000,这是不能被允许的。

如下面这段代码

  int i=10;
  int j=i;

CPU处理过程:

  1. 先把i的值从线程的工作线程读取到cpu的高速缓存中

  2. 把i的值赋值给j

  3. 把j的值从cpu高速缓存写入到线程的工作线程

整个操作就分3步完成的,这个过程就不具备原子性。在java中如果要保证原子性可以用锁来处理。

2.可见性

可见性:当多个线程访问同一个变量时,其中一个线程修改了变量的值,其他线程要立即看到修改后的值。

举个栗子:

  //线程1
  int n=0;
  n++;
  //线程2
  m=n;

cpu1执行线程1的代码,cpu2执行线程2的代码。cpu1首先需要把n的值从线程的工作线程中读取到cpu的高速缓存中,然后再执行+1操作,但是不会立即把n的值写入到线程的工作线程中。

此时cpu2执行操作时,先把n的值读取到高速缓存中,但是读取到的n的值还是原来的0,而不是1。这个就是可见性问题,线程1对于n修改的值,线程2不能立即看到。

在处理器层面,每个cpu都有自己的高速缓存,在多核并行执行的情况下就可能会出现每个cpu的高速缓存中变量的值不一致。为了解决这个问题,引入了CPU缓存一致性协议MESI。

缓存一致性协议MESI保证了每个cpu缓存中使用的共享变量的副本是一致的:当cpu要修改变量的值时,如果该变量是一个共享变量(这个变量也存在与其他cpu的缓存中),那么就给发送通知给其他cpu,把缓存中变量设置为无效状态。这样当其他cpu需要使用该变量时发现该缓存行是无效状态,就会重新读取该变量的值。

在java世界中,可以用锁或者Volatile关键字来保证变量的可见性。

3.有序性

有序性:在程序执行时为了提高性能,JVM编译器和处理器都会对操作进行指令重排序。指令的重排可能会造成代码执行先后顺序和书写的代码先后顺序不一样。

  1. JVM编译器中指令的重排序在不改变单线程程序语义的前提下会改变语句的执行顺序,但是重排序会遵循happens-before原则, 会考虑数据的依赖性,保证在单线程的场景下不会影响最终程序的执行结果,但是在多线程的场景下就可能会导致最终的计算结果不是预期的结果。

  2. 处理器的重排序是指令级并行的重排序。现代处理器都采用了指令级并行技术(Instruction-Level Parallelism, ILP),将多条指令并行的执行,处理器可能会改变语句对应的指令的执行顺序。

指令级并行 ILP:

如果程序中相邻的一组指令是相互独立的,即不竞争同一个功能部件、不相互等待对方的运算结果、不访问同一个存储单元,那么它们就可以在处理器内部并行地执行

举个栗子:

  //线程1
  int n=0;
  boolean flag=true;
  doSometing();
  flag=false;

  //线程2
  while(flag){
    doSometing2();
  }

上面的例子场景下,期望的结果是线程2先执行一段时间,直到线程1在执行过一段代码后把flag设置为false后,才跳出循环。

假设线程1中的flag=false这行代码和上面的doSometing()代码没有相互依赖的关系,JVM的指令重排可以会先执行flag=false后执行doSometing()代码。这种情况下就会导致线程2的doSometing2()根本不会被执行。

这个例子可以看出,单线程下指令的重排序不会影响到最终的执行结果,但是多线程并发执行的情况下会影响执行的正确性。

在java中可以通过volatile或者锁来保证代码的有序性。

Java中的指令重排必须遵循happens-before原则,规则如下:

  • 程序次序规则:一个线程内一段代码的执行结果是有序的,无论是否发生了指令重排序,最终的执行结果一定是按照我们代码的顺序生成的

  • 锁定规则:无论是在单线程还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,当另一个线程获取到了这个锁后就一定能看到前一个线程的操作结果。一句话来说就是:同一个锁的unLock操作一定先发生于后面对同一个锁的lock操作

  • volatile变量规则:一个线程先写一个volatile变量,那么其他去读取这个变量,写操作一定先行于读操作,写的结果一定对读线程是可见的

  • 传递规则:happens-before原则具有传递性,如果操作A在操作B之前,而操作B又在操作C之前,那么操作A一定在操作C之前

  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作

  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断

  • 线程终结规则:线程中所有的操作都先行发生于对此线程的终止检测,可以通过Thread.join()方法结束。也称线程join()规则

  • 对象终结规则:一个对象的构造函数的执行完成先行发生于他的finalize()方法的开始

内存屏障

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。

内存屏障可以被分为以下几种类型:

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。

  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

在并发编程中,如果要保证多线程程序执行的正确性,那么必须保证原子性,可见性,有序性三个特性都满足。如果有任何一个特性不满足,那么多线程程序就不是安全的。

锁的原理

锁是并发编程中绕不开的话题,在多线程的环境下,只要涉及到了资源的竞争就会有锁的存在,当多个线程同时执行一段代码,访问同一个资源,一个要删除,一个要更新,如果不加控制,那么结果就是不可预测的。锁可以保障一段代码同时只能被一个线程所访问,让多个线程可以顺序的执行,保障了程序执行的正确性。

锁从不同的维度可以分为:公平锁;悲观锁和乐观锁;轻量级锁,自旋锁和重量级锁。不同的锁实现的方式不同,需要消耗的资源也不相同。这些不同的锁在java中都有不同的实现。今天我们主要讲解下锁的基本概念和原理。

锁分为加锁和解锁两个命令,这两个命令一定是成对出现的,加锁之后一定要解锁,否则可能会造成死锁。锁通过加锁和解锁可以保证并发编程的三个特性的,下面我们来看看是如何实现的。

  1. 线程加锁时,会强制把线程的本地变量设置为无效,使被监视器保护的临界区代码必须要从主内存中去读取共享变量,让cpu的缓存和主内存保持一致。这个是通过读内存屏障(Load Memory Barrier)来实现的

  2. 加锁后在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定

  3. 被加锁的指令的前面和后面的指令都被禁止重排序,即前面的指令不能被重排序到后面,后面的指令不能被重排序到前面,保证了指令的有序性

  4. 线程解锁时,会强制把cpu缓存中的最新数据更新写入到主内存中,让其他线程可见,保证了指令的可见性,这个是通过写内存屏障(Store Memory Barrier)来实现的

锁的分类和定义

1.悲观锁和乐观锁

悲观锁:一个线性在执行一个操作时持有对一个资源的独占锁,一般是在并发冲突发生的概率大的情况下使用。
乐观锁:就是无锁算法,不需要持有锁,一种是通过CAS算法比较并替换的方式来解决冲突,一种是通过版本号/时间戳的方式比较和更新。一般是在并发冲突发生的概率比较小的情况下使用。

比如Synchronized,lock都是悲观锁。乐观锁常见的就是java.util.concurrent.atomic包下的各种以Atomic开头的类都是基于CAS算法实现的线程安全的类

2.偏向锁,轻量级锁、重量级锁

这三种锁是指锁的状态,主要是指Synchronized锁在不同情况下锁逐渐升级的过程

偏向锁:是通过对象头的MarkWord来实现的,MarkWord中存放获取了锁的线程ID,当该线程多次访问这个加锁对象时,会判断MarkWord中是否存在该线程ID,如果存在就不需要额外加锁操作,性能很好。
轻量级锁:线程A对锁对象持有偏向锁,另外一个线程B来访问时,会先假设竞争不激烈,线程A很快就会释放偏向锁,所以线程B会自旋等待线程A释放锁。自旋的方式获取锁叫做轻量级锁
重量级锁:当线程B自旋到一定次数后还会获取到锁,或者是线程C也来竞争锁对象,这时情况就不乐观了,轻量级锁会膨胀成重量级锁。申请重量级锁的线程B和线程C都会进入阻塞,性能降低

3.自旋锁,可重入锁

自旋锁:自旋锁指请求锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁。好处是不需要进行线程的上下文切换,坏处是循环占用CPU
可重入锁:可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。好处是避免同一个线程多次获取锁时发送死锁的情况

java中Synchronized锁升级的第二阶段就是自旋锁。ReetrantLock和Synchronized都是可重入锁

4.公平锁和非公平锁

公平锁:多个线程按照申请锁的顺序来获取锁
非公平锁:多个线程并不是按照申请锁的顺序来获取锁,而是每次获取锁时会尝试先插队去获取锁,获取不到在排队获取

java中Synchronized锁就是一种非公平锁。而ReetrantLock会在构造函数中指定锁是否是公平锁。
非公平锁比公平锁吞吐量要大,但是可能会造成前面的线程一直获取不到锁,形成锁饥饿现象

5.独享锁和共享锁

独享锁:一个锁同时只能被一个线程持有
共享锁:一个锁可以同时被多个线程持有,一般多用于多个线程共享一个读锁

6.分段锁

分段锁:分段锁是一种锁的设计手段。最经典的例子就是ConcurrentHashMap的设计,通过分段锁的设计可以实现高效的并发操作。

ConcurrentHashMap的原理是把键值对列表分成多个Segment,每一个Segment都是一个类似于HashMap结构的对象,Segment继承ReentrantLock所以也提供了锁的功能。当对ConcurrentHashMap进行put时,首先对key进行hash算法计算出要落到哪个Segment中,然后对该Segment进行加锁后计算。因为没有对整个ConcurrentHashMap加锁,此时其他进来的put请求如果是落到另外的Segment中,是可以并行的进行操作的,所以它的并发的性能就非常好。

参考引用

Java内存访问重排序的研究

posted @ 2020-08-15 13:43  我家有奥特曼  阅读(731)  评论(0编辑  收藏  举报