线程安全问题

线程安全问题

并发编程,为了保证数据的安全,需要满足以下三个特性:

  • 原子性:就是在一个操作中cpu不可以在中途暂停然后再调度,既不被中断操作,要么执行完成,要么就不执行。
  • 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 有序性:程序执行的顺序按照代码的先后顺序执行。

总结:缓存一致性问题 其实就是 可见性问题。而 处理器优化 是可以导致 原子性问题 的。指令重排 会导致 有序性问题

如何保证线程安全

JMM:一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问效果一致的机制及规范。

synchronized关键字是一种原子性内置锁。内存语义:进入synchronized代码块(获得锁)会清空锁内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存中进行加载;退出synchronized代码块(释放锁)时将本地内存中修改的共享变量刷新到主内存中。可以解决共享变量原子性、内存可见性及有序性问题,但是会导致线程上下文切换并带来线程调度开销。

volatile关键字是一种解决共享变量内存可见性和有序性的非阻塞方案(通过 内存屏障 实现),但是并不能保证原子性。以下两个场景中可以使用volatile来代替synchronized:1、运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值;2、变量不需要与其他状态变量共同参与不变约束。

已经有了缓存一致性协议(MESI),为什么还需要volatile?1、并不是所有的硬件架构都提供了相同的一致性保证,Java作为一门跨平台语言,JVM需要提供一个统一的语义。2、操作系统中的缓存和JVM中线程的本地内存并不是一回事,通常我们可以认为:MESI可以解决缓存层面的可见性问题。使用volatile关键字,可以解决JVM层面的可见性问题。3、缓存可见性问题的延伸:由于传统的MESI协议的执行成本比较大,所以CPU通过Store Buffer和Invalidate Queue组件来解决。但是由于这两个组件的引入,也导致缓存和主存之间的通信并不是实时的。也就是说,MESI协议,可以保证缓存的一致性,但是无法保证实时性。

CAS(Compare and Swap)是JDK提供的非阻塞原子性操作,它通过硬件保证比较-操作的原子性。JDK中的Unsafe类提供了一系列compareAndSwap*方法。

实现方式:CAS方法对应的有4个操作数,分别为对象内存位置(obj),对象中的变量的偏移量(valueOffset),变量预期值(expect)和新的值(update)。其操作含义是,如果对象obj中偏移量为valueOffset的变量的值为expect,则用新值update替换旧值expect。这是处理器提供的一个 原子性指令

伪共享:缓存行作为CPU高速缓存与主存进行数据交换的单位,当多个线程同时修改同一个缓存行中的多个变量时,由于MESI协议的存在,导致缓存行频繁失效,影响效率。

如何避免伪共享:1、通过字节填充的方式,保证被操作的属性占用的字节数加上前后填充的字节数不小于一个缓存行占有的字节数即可,典型的以空间换时间的操作;2、JDK8之后可以使用注解 @sun.misc.Contended 来做缓存行的隔离,注意使用此种方案需要添加JVM参数-XX:-RestrictContended。具体可参考这篇文章

锁相关

乐观锁与悲观锁:悲观锁认为数据很容易被其他线程修改,所以在数据被处理前先加排他锁,例如在select语句后加for update加行锁。乐观锁认为数据在一般情况下不会造成冲突,所以在访问前不会加排他锁,而是在进行数据提交更新时,才会正式对数据进行校验。例如加版本号标识,在数据被更新时进行版本号校验,更新成功后版本号加一,有点类似CAS操作。

公平锁与非公平锁:公平锁标识线程获取锁的顺序是按照线程请求锁的先后时间来决定的,类似队列的思想,非公平锁则不存在这样的限制。ReentrantLock提供了公平锁和非公平锁的实现:

公平锁-ReentrantLock pairLock = new ReentrantLock(true)。

非公平锁-ReentrantLock pairLock = new ReentrantLock(false)。如果构造函数不传递参数,则默认为非公平锁。

独占锁与共享锁:独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock就是以独占锁的方式实现的。共享锁则可以同时由多个线程持有,例如ReadWriteLock读写锁,它允许一个资源可以被多线程同时进行读操作。独占锁是一种悲观锁,共享锁则是一种乐观锁。

可重入锁:当一个线程再次获取它自己已经获得的锁时不会被阻塞,那么我们说该锁是可重入的。synchronized内部维护着计数器monitor和锁的当前持有线程来实现可重入。

锁的优化:java中的线程是与操作系统中的线程一一对应的,线程的阻塞挂起和唤醒会导致用户态和内核态的切换,开销很大。synchorized监视器锁在JDK6后优化了很多,当只有一个线程请求锁时锁的状态为 偏向锁,有多个线程请求锁但不存在竞争时锁的状态为 轻量级锁,存在竞争后也不会立即膨胀为重量级锁,而是会进行 自旋 重试,如果重试一定的次数后还没能获得锁则锁的状态才会升级到 重量级锁。还有一点,锁的状态只能升不能降。