之前有一篇文章简单的讲了一下线程的几种状态以及状态之间的转换
今天再继续讲一些关于线程的问题:线程安全问题以及线程安全的实现方法
一、线程调度的两种方式以及线程安全的问题
大家知道Java的线程是映射到操作系统的原生线程之上的,线程调度是指系统为线程分配处理器使用权的过程
目前主要有两种线程调度方式:协同式线程调度和抢占式线程调度
1、协同式
使用这种线程的调度方式时,线程的执行时间由线程本身来控制,线程把自己的工作执行完成之后,要主动通知系统切换到另一个线程上。协同式多线程的最大好处就是实现简单,而且由于线程要把自己的事情干完后才会进行线程的切换,切换操作对线程自己是可知的,所以没有什么线程同步的问题。它的坏处也很明显:线程时间不可控制,甚至如果一个线程编写的有问题,一直不告诉系统进行切换,那么程序就会一直阻塞在那里,所以使用协同式来实现多线程任务是相当不稳定的,一个进程坚持不让出CPU执行时间就很可能会导致整个系统崩溃。
2、抢占式
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。在这种实现线程调用的方式下,线程的执行时间是系统可控的,也不会有一个线程导致进程阻塞的问题,Java使用的线程调度方式就是抢占式调度。
因为是抢占式的调度方式,所以每个线程什么时候获得CUP执行权,什么时候执行权被切换走,都是系统决定的,所以就很可能会出现A线程执行到一半的时候,CPU执行权被切换给B线程,恰好这两个线程又都在访问堆里的同一个共享的数据,当切换给B线程的时候,B线程把刚才A拿到的数据改了,B执行完再切换给A线程继续执行,那这时候A线程拿的数据就已经是脏数据了,这就会有安全问题
再加上Java的内存模型里把内存分为主内存和工作内存,Java内存模型规定了所有变量都存储在主内存,每个线程还有自己的工作内存,线程的工作内存中保存了被当前线程使用到的数据,这些数据是从主内存拷贝过来的,还规定线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存里的变量,线程间变量值的传递均需要通过主内存来完成。线程、主内存、工作内存三者交互的关系图如下图所示
而线程在工作内存中修改完、操作完对应的变量后,这个修改完的变量值到底什么时同步给主内存又是不确定的,是系统决定的(可以暂且理解为是随机的)。所以这也导致了多线程操作共享数据时的不安全性
二、线程安全的实现方法
了解了线程安全的问题后,接着的一个问题就是我们应该如何实现线程安全
1、互斥同步
互斥同步是常见的一种并发正确性保障手段。同步是指在多线程并发访问共享数据时,保证共享数据同一时刻只被一个线程使用。那怎么实现呢,我们可以使用互斥的手段
在java中最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个指令都需要一个reference类型的参数来指明要锁定和要解锁的对象。如果Java代码里synchronized同步块儿明确指定了这个对象参数,那这个参数就是明确指定的这个对象。如果没有明确指定的话,那就根据synchronized关键字修饰的实例方法还是静态方法(类方法),去取对应的对象实例或者取当前类对应的Class类的对象来作为锁对象。
根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取锁对象,如果这个对象没被锁定,或者当前线程已经拥有这个对象锁的话,就把锁计数器加1,相应的在执行monitorexit指令的时候,锁计数器减1,当计数器为0时,锁被释放。如果获取锁对象失败的话,那当前的线程就需要阻塞等待,直到对象锁被另一个线程释放为止。
Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙完成,这就需要从操作系统的用户态转换到核心态,这个转换需要消耗很多的资源和处理器时间,对于简单的代码同步块儿,很可能执行同步代码的时间还没这个状态转换消耗的时间长,所以synchronized是Java里一个重量级的操作,一般有经验的程序员都会在确实有必要的情况下才会去使用这种操作。而虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞一个线程之前,会加入一段自旋等待的过程(自旋锁)来避免过于频繁的切入到核心态。
除了synchronized之外,我们还可以使用 java.util.concurrent(J.U.C)包中的重入锁ReentrantLock来实现同步,ReentrantLock和synchronized很类似,都具备一样的线程重入性(就是自己不会把自己锁死),只是代码写法上有些区别:synchronized是原生语法层面的互斥锁,ReentrantLock是API层面的互斥锁(lcok()和unlock()方法配合try/finally语句块来完成)。另外相比synchronized,ReentrantLock增加了一些高级功能:等待可中断、可实现公平锁、可绑定多个条件。绑定多个条件这个经典的应用场景就是在生产者和消费者模式下,当生产者生产完之后去唤醒线程进行消费的时候,可以根据绑定的条件只唤醒消费者线程而不唤醒生产者线程。
2、非阻塞同步
互斥同步最主要的问题就是进行线程阻塞和唤醒需要切换入操作系统的核心态,这个状态转换需要消耗大量的处理器时间,因此互斥同步也称为阻塞同步。从处理问题的方式上来说,互斥同步它其实是属于一种悲观的并发策略,它认为只要不去做同步措施(例如加锁),那就肯定会出问题,无论你的共享数据是不是真的出现了多线程去竞争,它都要进行加锁、用户态核心态转换、维护锁计数器、检查是否有被阻塞的线程需要唤醒等操作(这里只是讨论概念模型,实际上现在的虚拟机会优化掉很大一部分不必要的加锁,这个后边有时间会继续讨论一下)。
随着硬件指令集的发展,我们可以有另外一个选择:基于冲突检测的乐观并发策略(最常见的就是CAS),通俗的说,就是我不加锁,我先检查一下看看是不是真的其他线程在争用共享数据,如果没有那我就操作;如果共享数据有争用,产生了冲突,那就采取其他的补偿措施(最常见的补偿措施就是不断的重试,直到成功为止),这种乐观并发策略的实现都不需要把线程阻塞掉,因此这种同步操作称之为非阻塞同步。
为什么我们说使用乐观的并发策略需要“硬件指令集的发展”才能进行呢?因为我们需要冲突检测和操作这两步具备原子性,那靠什么来保证呢?如果这里我们再使用锁,那和上边的阻塞同步有什么区别呢?所以我们只能靠硬件来完成这件事情,硬件保证一个从语义上看起来需要多步操作的行为只通过一条处理器指令就能完成,这类指令常见的有以下五种:
- 测试并设置
- 获取并增加
- 交换
- 比较并交换(Compare-and-Swap)简称CAS
- 加载链接/条件存储
其中最常见也最常使用的就是CAS了
CAS需要3个参数,分别是当前值V、旧的预期值A、新值B
CAS指令执行时,当且仅当当前值V 符合 旧的预期值A时,就把旧的A值更新成新值B,否则就不执行更新操作,但是无论是否更新成功,都会返回V的旧值。上述处理过程是一个原子操作。
上述是硬件级别的处理器指令集保证了虚拟机中“比较”和“交换”两个动作的原子性。
在JDK1.5之后,我们才可以在Java代码中使用CAS操作,该操作由sun.misc.Unsafe类里的compareAndSwapInt()和compareAndSwapLong()等几个方法包提供,虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令。但是由于Unsafe类不是提供给用户程序调用的类,因此,如果不采用反射手段的话,我们只能通过别的API来间接使用它,比如J.U.C中的整数原子类里compareAndSet()和getAndIncrement()等方法都使用了Unsafe类里的CAS操作。
以上就是对线程安全实现方法的一些记录
其实除了以上的两种方式外,还有其他的方式,如:无同步方案等,但这些不是今天讨论的重点
今天就先写到这里,后续有时间会继续总结一下虚拟机里的锁优化:自旋锁和自适应自旋锁、轻量级锁、偏向锁等内容