【java基础知识】 - 并发相关基本知识

152 阅读11分钟

这是我参与8月更文挑战的第17天,活动详情查看:8月更文挑战

并发相关的原理/知识

  • 并发三个特性
  • JMM模型
  • 锁的类型

参考博客:

他山之石可以攻玉,我这里也就只算是知识的罗列加上自己的补充,有不合适的地方希望各位读者不吝指出~

并发三大问题

  • 原子性(Atmoic):一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。zhuanlan.zhihu.com/p/110481625

  • 可见性(CAS/Volatile)

    • 线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的。如果每个核心共享同一个缓存,那么也就不存在内存可见性问题了。

  • 顺序性(重排序问题)(前后一致-重排)

    • 编译器优化(JVM)
    • 指令重排序(CPU)
    • 内存系统重排序(缓存的原因)

    是否想到了数据库的ACID(Atmoic/Consistent/Isolation/Duration),分布式的CAP(Consistency/Availability/Partition-tolerance)?这三者事实上都是对多方操作下应用的共同协作进行了规定,因此在事实上的规范中有相似之处。

以下统称操作的对象(线程/数据库链接持有方/应用)为worker进行描述。

  • 从并发三特性的角度:

    • 原子性和数据库是类似的,在并发条件下,对该特性的保障是为了使某个worker的在某个时间内工作区保持相对唯一,例如:

      • 并发条件下对临界区变量的修改需要根据需要进行加锁来实现原子性,保证临界区的变量是目前持有线程独有的。

      • 数据库需要用事务来保证原子性。

        但二者并不能说是完全相同的特性,介于应用场景不同,实现上是不同的。

    • 可见性和数据库的一致性,分布式的一致性是类似的,对该特性的保障是为了使worker之间可以正确的方式更轻松的相互工作:

      • 并发条件下的可见性,是为了保证并发条件下不同线程的修改相互之间可被立即查看到,更多强调的是过程中查看的一致性。
      • 数据库的一致性,目的是在于保持最终数据修改的结果是一致的,更多的是强调最终结果的一致性。
      • 分布式系统中的一致性,目的是在于保证请求紊乱的情况下,冲突的请求不会造成预期外的结果,强调的也是最终结果的一致性,数据库和并发的一致性更类似于分布式系统中的强一致性
  • 其他的特性都是针对实现粒度不同而针对实现对象特性做出的稳定性补充。

    • 并发

      • 并发的顺序性,是为了保证不会因指令集/CPU优化重排等底层优化,而造成程序与预期致性顺序不一致引发的结果,从目的上来看和数据库的独立性是类似的,即:通过某些特定的方式,使worker的操作顺序和预期一致,不会因为该worker的正确操作,在其他条件的影响下,导致不正确的结果
      • 并发的顺序性,是为了保证线程的操作可以按照预期设定的顺序进行,保证程序是可以按照指定逻辑运行的,从而不需要为了保证程序执行顺序,在所有地方都进行前后顺序的校验。
    • 数据库

      • 数据库的持久性是针对数据库特性而提出的,数据库的最终目的,就是为了保证数据能够正常的存储,持久性能保证数据库的任何更新(增删改)操作确实被保存起来了。
    • 分布式

      • 分布式的分区容忍性和可用性,本质上是为了使分布式系统可以在可容忍的失败率下使用。
    • 如果线程的执行无法保证任何逻辑的顺序性,数据库无法保证任何操作的持久性,分布式系统无法保证任何时间上的分区容忍性和可用性,那么对于这三者而言都是无法使用的。

      • 事实上:

        • 对于线程安全而言,顺序性是必须保证的,否则逻辑顺序无法正确映射到执行中。
        • 对于数据库而言,持久性是必须保证的,否则外部无法保证使用数据库时存储的正常进行。
        • 对于分布式系统而言,分区容忍性是必须保证的,否则集群无法正常工作。

JMM(JavaMemoryModel,java内存模型)

from : juejin.cn/post/684490…

  • 线程安全的定义

    当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。-------《深入理解java虚拟机》

出现线程安全的问题一般是因为主内存和工作内存数据不一致性重排序导致的,而解决线程安全的问题最重要的就是理解这两种问题是怎么来的,那么,理解它们的核心在于理解java内存模型(JMM)。

通俗点来说,JMM相当于线程条件下Java代码和底层操作系统(内存-CPU)之间的一个中间层,保证线程/多线程执行时的程序语义可以正确(至少是按照程序员的理解上正确)传递到底层操作系统。

两个规则/语义

这两个规则事实上都是对线程中的顺序性做出保证。

as-if-serial

(单线程条件下)

顾名思义,前后依赖相关的必须保证前后一致,即使经过重排之后,相关的顺序也必须保证一致。

happens-before

(多线程条件下)

在JUC中的package-info文件中简单描述了happens-before在java中的一个现象:

 The results of a write by one thread are guaranteed to be visible to a read by another thread only if the write operation happens-before the read operation. 

且指出了几个可以构造happens-before关系的案例:

 The {@code synchronized} and {@code volatile} constructs, as well as the
 {@code Thread.start()} and {@code Thread.join()} methods, can form
 happens-before relationships.

这是对于这些范式的描述(在可以正常通过编译,逻辑顺序正常的情况下)

  • synchronized

    • 加锁总在本线程释放锁之前;如果有锁,总在其他线程释放锁之后
  • volatile

    • Volatile变量写总在读之前。
    • Volatile变量的读写和monitor的进入退出在内存一致性的影像上相似,但并不包括互斥锁。
  • Thread.start,Thread.join

    • start的调用总在线程内代码执行之前被调用
    • join总在任何其他线程成功返回之后被调用。

    并且,JUC中的所有类和子包,都保证了happens-before。

锁类型

see : www.cnblogs.com/hustzzl/p/9…

对博客中的内容进行了一定的整理

1.根据实现思想区分

  • 乐观锁
  • 悲观锁

乐观/悲观指的是对数据修改频繁程度的一个度量而做出的相应的动作。

这个粒度上的区分,是实现思想上的区分,并不指定实现的方式。

1.1乐观锁

认为数据修改不频繁(或在容忍范围之内),因此每次取数据的情况下并不会独占数据(即其他人无法操作这块数据),只会在更新的时候去判断,这个数据是否被修改。如果被修改了就重新给本地副本(变量等)赋值,以方便更新操作。

适合的场景

读多写少,不加锁可以在保证乐观锁有效的前提下大幅度增加吞吐量。

在Java中的使用就是无锁编程,常常用CAS算法进行操作。

实现案例
  • 根据系统底层提供的指令cmpxchg 实现的CAS操作。

     CAS(Compare and Swap 比较并交换),当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

      CAS操作中包含三个操作数——需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B,否则处理器不做任何操作。

  • InnoDB中MVVC版本号控制

1.2 悲观锁

悲观锁就和乐观锁相反了,认为数据修改频繁,在容忍范围之外,因此要恰独食,用得时候就不给别人用(上锁),不然别人就吃了。

适合的场景

写多读少,或者应该说是一定需要操作成功的场景,使用悲观锁的成功率要远高于乐观锁。

实现案例
  • 数据库事务中串行化隔离级别的实现
  • java中Synchronized关键字(todo 在1.6众所周知的底层修改之后,不太确定到底是怎么演化的,是否还能称为悲观锁

2.根据占有权区分

  • 独享锁
  • 分享锁

  独享锁是指该锁一次只能被一个线程所持有。

  共享锁是指该锁可被多个线程所持有。

  对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。

  读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。

  独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

互斥锁/读写锁,就是上述思想的一个实现。

互斥锁在Java中的具体实现就是ReentrantLock。

读写锁在Java中的具体实现就是ReadWriteLock。

3.可重入锁

顾名思义,可重入锁,就是进入内部方法对同一个资源进行锁的时候,会正确的进行锁资源的转交,而不是因为该情况变成死锁。

  • 实现案例:

    • ReentrantLock
    • Synchronized

4.公平/非公平锁

公平-非公平,指的是是否有一个基于FIFO的序列,对请求顺序进行按照申请顺序的锁分配。

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。

  • 实现案例:

    • ReentrantLock可以通过构造函数指定公平/非公平,默认非公平。

      非公平锁的优点在于吞吐量比公平锁大。

    • Synchronized也是非公平锁。

5.分段锁

分段锁是通过对一组数据进行分段加锁取代对该组容器加锁,通过将整体强一致性变为部分强一致性,换取并发上的高性能。

  • 实现案例

    • Java中CHM 1.7及以前,通过Segment继承ReentrantLock,对每个链表上锁实现的并发可靠性。
    • DB中的行锁、间隙锁,设计思想上也是分段锁。

6. 偏向锁/轻量级锁/重量级锁

本质上都是悲观锁,只是在底层资源的锁粒度不同,三者的锁粒度从细到粗,且不会退化(很难退化)。

这三种锁是指锁的状态,并且是针对Synchronized。在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

  • 偏向锁

    • 偏向,指的是:

      • 如果一段同步代码一直同一个线程访问,那么该线程会自动获取锁。
  • 轻量级锁

    • 轻量级是相对重量级来说的。
    • 轻量级是指偏向锁被其他线程申请时,偏向锁此时会升级成轻量级锁,其他线程会通过自旋的形式尝试获取,不会阻塞
  • 重量级锁

    • 重量级锁是相对轻量级锁来说的。
    • 当轻量级锁中,其他线程自旋请求到一定次数时,会放弃自旋转而阻塞,此时称轻量级锁膨胀为重量级锁

7.自旋锁

自旋指的就是会通过循环而非阻塞的方式,去尝试获取锁。

这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。