多线程(二)-线程安全

一、概念

  • 并行与并发:1个核对1个线程是并行执行,1个核对多个线程是并发执行。
  • 线程安全:并发带来竞争,竞争的结果会让多个线程同时写某个共享变量时出现数据错误问题,该问题即线程安全问题。
  • 线程同步:解决线程安全问题的方式方法。

二、JMM与happens-before规则

2.1 JMM抽象结构模型:

我们知道CPU执行指令的速度是远远快于内存读写速度的,如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。因此,线程在执行的时候,不会直接读取主内存,而是会在每个CPU核的高速缓存里面读取数据,每次CPU在执行线程的时候,会将需要的数据从主内存读取到高速缓存中。JMM就从抽象层次定义了这种方式,并且JMM决定了一个线程对共享变量的写入何时对其他线程是可见的。

多线程(二)-线程安全_第1张图片
JMM抽象结构模型

因为jvm会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。

2.2 happens-before6条规则如下:
  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

三、三大性质:

  • 原子性:操作是一个整体,不可分割。这是批处理与回滚的概念,保证数据操作的线程安全。
  • 可见性:当多个线程访问同一个变量时,一个线程修改了变量的值,其他的线程能立即看到修改的值。这是保证数据在内存同步的线程安全。
  • 有序性:CPU不按程序规定的顺序执行指令,但是最终执行结果与程序顺序执行的结果一致。这是站在编译器与处理器是否做重排序的角度保证线程安全。

通过三大性质来保证线程安全。

四、同步方案

4.1 乐观锁 volatile + CAS
volatile

volatile是Java关键字,仅保证可见性、有序性,不保证原子性。

  • 可见性:变量如果被声明为volatile,就是告诉JVM,这个变量是不稳定的,每次读变量都从内存中读,跳过 CPU cache 这一步。一般说来,多任务环境下,各任务间共享的变量都应该加volatile修饰符。

  • 有序性:有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障,即指令重排序时不能把后面的指令重排序到内存屏障之前的位置,最终实现禁止指令重排序优化。

  • 原子性:不保证原子性,它需要配合CAS(compare and swap),来保证原子性。

CAS

CAS操作(又称为无锁操作)是一种保证原子性的机制,它不是通过加锁阻塞其他线程操作,而是通过比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。

交换过程:
三个值:内存地址存放的实际值 、预期旧值 、更新的新值
如果实际值=旧值,证明没有更新过,直接将新值赋给实际值。
如果实际值≠旧值,证明被修改,不能将新值赋给实际值,直接返回之前的实际值。

当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。

CAS的问题:

  • ABA问题:旧值A变为了成B,然后再变成A,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。
  • 自旋时间长问题:CAS有竞争会自旋,如果长时间得不到执行,会造成明显cpu消耗。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。
  • 只能保证一个共享变量的原子操作:对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。但是可以将多个变量整合为对象,然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。
4.2 悲观锁

Java 中两种实现加锁的方式:一种是使用 synchronized 关键字,另一种是使用 Lock 接口的实现类,使用比较多的是ReentrantLock。

1)synchronized:Java关键字

  • 可见性:
    JMM关于synchronized的两条规定:
      1)线程解锁前,必须把共享变量的最新值刷新到主内存中
      2)线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值
       (注意:加锁与解锁需要是同一把锁)
    通过以上两点,让synchronized能够实现可见性。

  • 有序性:synchronized无法禁止重排序,但是他保证了多线程串行执行,同一时刻只有一个线程执行。但是编译器和处理器无论如何重排序都要遵循as-if-serial原则,这个原则保证不管怎么重排序,单线程程序的执行结果都不能被改变。因此synchronized只要保证了串行的单线程执行,那么相当于也保证了有序性。

  • 原子性:synchronized与字节码指令:monitorenter和monitorexit对应,monitor机制保证了synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放前,无法被其他线程访问到。

synchronized虚拟机优化介绍

锁优化过程:偏向锁->自旋锁->重量级锁

  • 偏向锁:当一个已经持有锁的线程再次请求锁时无需再做任何同步操作。在针对几乎是同一个线程反复请求锁而没有锁竞争的场景下,节省大量有关锁申请的操作,性能非常高。

  • 自旋锁:当线程暂时无法获得锁时,系统乐观的认为该线程将在不久的将来得到锁,虚拟机会让该线程自旋几次(虚拟机会设一个自旋最多次数,比如10次),即循环判断是否可以获取锁了。自旋会占用CPU资源,但是轻量的自旋还是优于锁申请的相关操作,因此轻微的竞争或者段时间持锁的场景下,自旋锁性能相对比较好。

  • 重量级锁:如果自旋超过最大限制,证明锁竞争严重,则会升级为重量级锁,等锁的线程会被挂起,等待释放锁的线程去唤醒。这是在锁竞争严重的场景下的策略,减少自旋的CPU消耗。

2)RentrantLock

RentrantLock:实现锁功能的类,是 Lock 接口的实现类,这里挑RentrantLock简单看看。

引用知乎的一段理解:
ReentrantLock的操作都是委托给其内部抽象类Sync的实例来实现的,但他们实现的lock/unlock原理是通过改变父类中AbstractQueuedSynchronizer中的private volatile int state变量的值来表示加锁、重入、解锁状态的。这里使用到了volatile。当state=0的时候,表示没有线程持有锁,如果此时有线程A调用reentrantLock.lock(),会调用Unsafe类通过CAS的方式将state加1设置为1,此时当前线程A持有锁,其他线程调用reentrantLock.lock(),发现state>0(由于ReetrantLock可重入,故A可以多次调用reentrantLock.lock(),所以会出现state>1的情况),会阻塞等待A线程释放锁,当A调用reentrantLock.unlock()时,state会减去1,调用后如果state=0,那么线程A就释放了锁,此时其他线程发现发现为任何线程持有锁(重点:即感知到了state=0),那么就会尝试获取锁,即把state设置为1,这样根据之前的happens-before原则,volatile就保证了可见性。

自我解读:RentrantLock的lock和unlock功能最终是靠volatile + CAS实现的。但是他毕竟还是加锁阻塞,因此还是悲观锁。

3)synchronized 与ReentrantLock区别:

支持的功能:

加锁方式 可重入 公平非公平 可中断 互斥
synchronized 非公平
ReenTrantLock 可以在构造函数指定,默认非公平

注:

  • 可重入锁:可以重新进入的锁,即允许同一个线程多次获取同一把锁,递归操作不会出现死锁。
  • 公平锁:多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁。
  • 非公平锁:按一定策略来让某些线程优先获取锁,在大多数情况下,非公平锁的吞吐量比公平锁的大,这个我自己没考证过。
  • 可中断锁:可以响应中断的锁,不想一直等可以中断,避免死锁。
  • 互斥锁:互相排斥的锁,也就是表明锁只能被一个线程拥有。

区别总结

  • 原理区别:ReentrantLock是类,synchronzied是关键字;Lock锁是通过代码实现的,synchronzied是托管给jvm执行的;
  • 用法区别:synchronized既可以加在方法上,也可以加载特定的代码块上,括号中表示需要锁的对象。而Lock需要显示地指定起始位置和终止位置。
  • 功能区别:ReentrantLock支持公平非公平设置,和可中断。前者设置在竞争激烈的情况下,性能会优于synchronized,后者ReentrantLock可以让等待锁的线程响应中断而synchronized不行,
乐观锁适用场景 悲观锁适用场景
多读场景 多写场景
当读写冲突发生的很少时,采用乐观锁可以省去加锁的开销,提高整个系统的效率。若冲突发生很多的时候,会使得不停地进行尝试,反而降低了效率 当冲突发生的很多时,直接采用悲观锁,可以有效地保证线程安全,

先简单总结这么一些吧。

参考:
https://www.jianshu.com/p/d53bf830fa09
https://www.jianshu.com/p/d52fea0d6ba5
https://www.zhihu.com/question/41016480?sort=created

你可能感兴趣的:(多线程(二)-线程安全)