Java并发编程(8)---并发编程学习总结

前言

学习并发编程相关的知识已经有一个月有余。现在对相关的知识做一个总结。
本总结主要介绍线程不安全的根源,Java内存模型,锁的基础知识,已经线程间的通信。每个知识点都有相应的demo。我将从如下几个方面进行总结:

为什么要加锁

多个线程操作同一个共享变量时,就可能会出现可见性,原子性和有序性的问题。

可见性问题

我们将一个线程对共享变量对另一个线程可见称之为可见性,由于在多核计算机中,线程获取CPU的执行权,操作共享变量之后可能会将操作后的数据存入CPU缓存(也就是线程私有栈)
如图:
Java并发编程(8)---并发编程学习总结
线程A和线程B都对变量为进行操作,然后将操作后的数据存入各自的私有栈中,就会出现可见性问题。(代码示例如:LongTest)

原子性问题

原子性:一个操作的内部状态对外界不可见称之为原子性,一般认为一条CPU指令是具有原子性的。而一条高级语句可能会分为多条指令执行,例如: i=i+1
1. 将i的值从内存读取到CPU寄存器中
2. 执行i+1 操作得到A值
3. 将A值赋值给i,并写入内存中(或者是CPU缓存中)
在每个CPU指令执行完之后都有可能会发生线程切换,因为在操作系统允许某个进程一小段时间,例如:50毫秒,50毫秒之后操作系统就会发生任务切换,这一小段时间就称之为“时间片”。
早期的操作系统是基于进程调用CPU的,不同的进程间是不共享内存空间的。所以进程要做任务切换就需要切换内存地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以,线程间切换的成本比较低,现在操作系统都是基于更轻量的线程来调度,这里的任务切换一般指"线程切换"。
Java并发编程(8)---并发编程学习总结
比如执行 i=i+1 语句时,线程A执行到第二条指令时,发生了线程切换,切换到线程B执行,那么线程B读取到的值还是旧值,就会出现问题。

有序性

在操作系统中一条CPU指令可以分为很多步骤,简单地说,可以分为以下几步:
1.取值IF
2.译码和取寄存器操作数ID
3.执行或者有效地址计算EX
4.存储器访问MEM
5.写回WB
例如
例如:A=B+C的这个操作,写在左边的指令就是汇编指令,LW表示load,
其中LW R1,B,表示B的值加载到R1寄存器中,ADD指令就是加法,把R1,R2的值相加,并存放在R3中, SW表示store,存储,就是将R3寄存器的值保存到变量A中。
我们的汇编指令也不是一步就可以执行完毕的,在CPU实际工作是,它还是需要分为多个步骤依次执行的,当然,每个步骤所涉及的硬件也可能不同,比如,取指时会用到PC寄存器和存储器,译码时会用到指令寄存器组,执行是会使用ALU,写回时需要寄存器组。
注意:ALU指算数逻辑单元,他是CPU的执行单元,是CPU的核心组成部分,主要功能是进行二进制算数运算。
由于每一个步骤都可能使用不同的硬件完成,因此,聪明的工程师们就发明了流水线的技术来执行指令,如下所示:显示了流水线的工作原理:
指令1 IF ID EX MEM WB
指令2 IF ID EX MEM WB
那么,A=B+C 的指令执行顺序就是
LW R1,B IF ID EX MEM WB
LW R2,C IF ID EX MEM WB
ADD R3,R1,R2 IF ID X EX MEM WB
SW A, R3 IF X ID EX MEM WB
由于ADD那一步发生了中断,所以导致,后续的步骤也中断了一步。
在比如:
A=B+C
D=E-F
重排前是:
LW Ra,B IF ID EX MEM WB
LW Rc,C IF ID EX MEM WB
ADD Ra,Rb,Rc IF ID X EX MEM WB
SW A, Ra IF X ID EX MEM WB
LW Re E IF ID EX MEM WB
LW Rf F IF ID EX MEM WB
SUB Rd,Re,Rf IF ID X EX MEM WB
SW D,Rd IF X ID EX MEM WB
重排前中断了两步,所以为了减少CPU流水线的中断事件,提升CPU的处理性能,操作系统会对CPU指令进行重排。重排后的执行就是:
LW Rb,B IF ID EX MEM WB
LW Rc,C IF ID EX MEM WB
LW Re E IF ID EX MEM WB
ADD Ra,Rb,Rc IF ID EX MEM WB
LW Rf F IF ID EX MEM WB
SW A, Ra IF ID EX MEM WB
SUB Rd,Re,Rf IF ID EX MEM WB
SW D,Rd IF ID EX MEM WB
如上所示:重排之后CPU流水线就没有发生中断了,指令重排可以保证单线程中程序的执行顺序,但是不能保证多线程间的执行顺序。

Java内存模型(JMM)

Java并发编程(8)---并发编程学习总结
如图所示:我们通过无锁CAS机制和锁机制,synchronized来处理原子性问题,因为被加锁的代码块是互斥执行的。通过synchronized和volatile来处理可见性问题,通过happens-before原则来处理有序性问题。
Java内存模型的概念:Java内存模型规范了JVM如何按需禁用缓存和编译优化(本质上是指令重排)的方法。

volatile

通过volatile关键字修饰的共享变量,JVM会强制其从公共堆栈(内存)中读取变量的值,而不是从县城私有数据栈中读取变量的值例如(VolatileTest)

Happens-Before规则(先行发生于:即前面的执行结果对后面可见)

  1. 程序次序规则: 程序前面对某个变量的修改对后续操作一定是可见的
  2. volatile变量规则:volatile 修饰的变量,写操作先行发生于读操作(本质volatile修饰的变量是强制从公共堆栈中取得变量的值,而不是从线程私有数据栈中取得变量的值)(代码:VolatileTest)
  3. 管程的解锁操作先行发生于加锁操作(代码SynchronizedTest1 )
  4. 传递性规则:A先行发生于B,B先行发生于C,则A先行发生于C(代码: VolatileTest )
  5. 线程的start规则:线程的start方法先行发生于该线程内的所有操作
  6. 线程的join规则:线程内所有操作都先行发生于join操作(代码: LongTest )
  7. 线程的interrupted规则:线程的interrupted先行发生于检测中断发生的方法

synchronized与等待通知机制

synchronized是为了解决并发编程中的原子性问题的,synchronized修饰的代码块称之为临界区,被保护的资源被称为受保护资源,如图:
Java并发编程(8)---并发编程学习总结

  1. 被synchronized修饰的代码块被称为临界区,是互斥执行的(代码SynchronizedTest1类)。
  2. 被synchronized 修饰静态方法,锁对象是类的Class对象(代码SynchronizedTest 类)
  3. 被synchronized 修饰的实例方法,锁对象是类的实例对象this
  4. 被synchronized修饰的代码块,锁对象就是()内的对象
  5. 锁对象在加锁和解锁时必须是不变的,不能使用一个可变对象作为锁(代码NoSafeSynchronizedTest1)

锁与受保护资源的关系

在并发编程中锁与受保护资源的关系是一对多的关系,一把锁可以保护多个资源,这种情况就类似于包场,当然一把锁也可以只保护一个资源。需要注意的是相同的受保护资源必须用同一把锁,比如图中的受保护资源balance,取款用的是balLock锁,则查询余额也需要用这个锁,如果不是的话,这不能保护相应的资源,这就类似于用你的锁保护你们家的资源。(代码Account和代码BadAccount)
Java并发编程(8)---并发编程学习总结

等待通知机制

我们想想一个就医流程:

  1. 患者先去挂号,然后到就诊门口分诊,等待叫号(类似于线程在等待队列中)
  2. 当叫到自己的号时,患者就可以去找大夫就诊了(类似于线程已经获取到锁了)
  3. 就诊过程中,大夫可能会让患者做检查,同时叫下一位患者(做检查,类似于线程要求的条件没有满足,患者去做检查,类似于线程进入等待状态;然后,大夫叫下一个患者,这个步骤对应到程序里,本质是线程释放持有的互斥锁。)
  4. 当患者做完检查后,拿检测报告重新分诊,等待叫号(类似与线程要求的条件已经满足,患者拿检测报告重新分诊,类似于线程需要重新获取互斥锁)
  5. 当大夫再次叫到自己的号时,患者再去找大夫就诊。
    从上述就医流程,我们可以总结出等待通知机制的工作原理:
  6. wait的工作原理是(如图所示):
    Java并发编程(8)---并发编程学习总结
    线程在等待队列中排队进入synchronized代码块内,当条件不满足时,调用wait方法。调用wait方法之后,线程会释放占用的资源,重新进入等待队列。
  7. notify的工作原理是(如图所示):
    Java并发编程(8)---并发编程学习总结
    调用notify或者notifyAll() 之后会通知等待队列中的线程,这两个方法的区别是notify只会随机通知某一个线程,而notifyAll则会通知等待队列中的所有线程。(参考代码:WaitNotifyTest)模拟生产者,消费者模式,队列满了时生产者等待,队列为空时消费者等待。

wait的两个问题

  1. 为什么wait()和notify()需要搭配synchonized关键字使用?
    调用wait() 的前提是线程获取到锁,而放在synchonized内部线程一定是已经获取到锁的,同时wait()和notify()的操作必须是互斥的。如果在synchonized{}外部调用wait或者notify会抛出java.lang.IllegalMonitorStateException。
  2. wait与sleep的区别?
  • wait(),notify(),notifyAll()一定是在synchronized{}内部调用,等待和通知的锁对象必须要对应。
  • wait 会释放锁对象的“锁标志”,当调用某一对象的wait()方法后,会使当前线程暂停执行,并将当前线程放入对象等待池中。知道调用notifyAll()方法,而sleep则不会释放。(代码WaitTest和SleepTest)
  • wait可以被唤醒,sleep的只能等其睡眠结束(代码WaitTest2 )
  • wait是在Object 类里,而sleep是在Thread 里的(wait()方法放在Object类的原因是:理论上任何 Object类对象可以作为锁对象,而wait(),notify(),notifyAll()都是由锁对象来调用的,所以Java把wait()方法放在Object类中,而sleep是针对线程的,只能由线程来调用)

死锁

哲学家问题:

5个哲学家,5跟筷子,5盘意大利面,大家围绕桌子而坐,进行思考或进食活动,哲学家除了吃面、还要思考、所以要么放下左右手筷子进行思考、要么拿起两个筷子(自己两侧的)开始吃面。哲学家从不交谈,这就很危险了,很可能会发生死锁,假设每个人都是先拿到左边的筷子,然后去拿右边的筷子,那么就可能会出现如下情况。(代码DeadLockTest2 )
Java并发编程(8)---并发编程学习总结
从图中我们可以看出死锁发生的四个必须的条件是:

  1. 互斥,共享资源A和B只能被一个线程占用
  2. 占有且等待,线程T1持有共享资源A,在等待共享资源B时,不释放占用的资源
  3. 不可抢占,其他线程不能强行占用线程T1占用的资源
  4. 循环等待,线程T1等待线程T2占用的资源,线程T2等待线程T1占用的资源

死锁避免的方法

  1. 破坏占用且等待(一次性申请所有的资源)(代码DeadLockDealTest1类)
  2. 对于不可抢占资源,占有部分资源的线程进一步申请其他资源,如果申请不到则主动释放它占用的资源(代码DeadLockDealTest2类)
    对于这个处理方式,只能运用lock来解决,这个后面会提到。
  3. 对于循环等待,可以靠按序申请资源来预防,所谓的按序申请,是指资源是有线性顺序的,申请的时候可以先申请序号小的,在申请序号大的。(代码DeadLockDealTest3类)

lock

与synchronized相比lock支持三个特性,可以解决死锁中的破坏不可抢占条件。 lock的标准使用如图 (代码LockTest)

  1. 能够响应中断 ( void lockInterruptibly() throws InterruptedException;)
    synchronized的问题是,持有锁A后,如果尝试获取锁B失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。如果阻塞状态的线程能够响应中断信号,也就是说当我们给阻塞线程发送中断信号的时候,能够唤醒它,那么它就有机会释放曾经持有的锁A(代码LockInterruptiblyTest )。
  2. 支持超时( boolean tryLock(long time, TimeUnit unit) throws InterruptedException;)
    如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那么这个线程也有机会释放曾经持有的锁,
  3. 非阻塞地获取锁(boolean tryLock()????
    通过调用tryLock()方法,如果返回true,则表示获取到锁,如果返回false,则表示获取锁失败,不过其并不会进入阻塞状态,而是直接返回。(代码TryLockTest )
    Java并发编程(8)---并发编程学习总结

lock的类图

Java并发编程(8)---并发编程学习总结
如图所示:主要是两个接口,一个接口是lock,其实现类有ReententLock,ReentrantReadWriteLock.ReadLock (读锁)和ReentrantReadWriteLock.WriteLock(写锁)。
另外一个接口是ReadWriteLock,其实现类是ReentrantReadWriteLock。
其中Condition接口有lock 产生Condition实例。

Condition

  1. 利用synchronized关键字与wait()和notify/notifyAll() 方法结合实现等待/通知机制,
  2. 同样的运用ReentrantLock类与Condition接口同样可以实现等待/通知机制。Condition中的await(), signal()和signalAll()分别对应Object中的wait(),nitify()和nitifyAll()。
  3. 一个lock对象可以创建多个Condition实例,线程对象可以注册在指定的Conditon中,从而可以实现同一个代码块中可以实现基于多条件的线程间的挂起和唤醒操作。(代码 LockConditionTest )用ReentrantLock实现生产者和消费者模式,创建了两个Condition。

公平锁和非公平锁

使用ReentrantLock的时候,你会发现ReentrantLock这个类有两个构造函数,一个是传入fair参数的参数,fair参数代表的是锁的公平策略,
1.公平锁:
如果传入true就表示需要构造一个公平锁,公平锁表示线程获取锁的顺序是按照线程加锁的顺序来分配的,即先来先得的FIFO先进先出顺序
2. 非公平锁:
非公平锁就是一种获取锁的抢占机制,是随机获取锁的,和公平锁不一样的就是先来的不一定先的到锁,这样可能造成某些线程一直拿不到锁,结果也就是不公平的了。(代码FairLock)

读写锁

ReentrantReadWriteLock : 读写锁

  1. 读写锁分为读锁和写锁,为了提高效率,读锁与读锁之间是不互斥的,可以多个线程同时读
  2. 写锁与写锁之间是互斥的,同一时刻只能有一个线程获得写锁进行写操作。(代码:ReadWriterLockTest) ReentrantReadWriteLock 与ReentrantLock 比较的话,主要是并发读的性能比较高,适合于读多写少的情况。

乐观锁(CAS)

前面介绍了synchronized和lock都属于悲观锁,即同一时刻只能有一个线程拥有受保护资源。下面我们介绍一下乐观锁,Java中的乐观锁是通过CAS算法来实现的。CAS的全称是( Compare And Swap(比较与交换) )
Java中的原子类:AtomicLong,AtomicInteger等就是通过CAS自旋来实现的。其有三个操作数:
需要读写的内存值V
进行比较的值A
要写入的新值B
当且仅当V的值等于A时,CAS通过原子方式用新值B来更新V的值(比较+更新)整体是一个原子操作(因为CAS指令是一条CPU指令)。(代码:AtomicLongTest)

乐观锁的原理:

Java并发编程(8)---并发编程学习总结
其中:

  1. unsafe:获取并操作内存的数据
  2. valueOffset:存储value在AtomicInteger中的偏移量。
  3. value:存储AtomicInteger的int值,该属性需要借助volatile关键字保证其在线程间是可见的。
    Java并发编程(8)---并发编程学习总结
    unsafe.getAndAddInt()方法,循环获取给定对象o中的偏移量处的值v,然后判断内存值是否等于v。如果相等则将内存值设置为 v + delta,否则返回false,继续循环进行重试,直到设置成功才能退出循环,并且将旧值返回。整个“比较+更新”操作封装在compareAndSwapInt()中,在JNI里是借助于一个CPU指令完成的,属于原子操作,可以保证多个线程都能够看到同一个变量的修改值。
    自旋就是通过无阻塞的循环调用compareAndSwapInt方法。

各种锁的性能比较

下面我们来看看某Map对象高并发下的读写线程安全测试,对synchronized,ReentrantLock,ConcurrentHashMap的性能比较。(代码:MapTest)
环境:mac OS 8G内存,2核,Jdk 1.8
从测试结果,我们可以看出,并发容器ConcurrentHashMap性能始终是最好的,当并发线程数小是三者的性能差距不大,当并发数达到1770时,三者出现了明显的性能差异。
故在实际项目中,应该优先使用并发容器,当其不满足时在使用synchronized, synchronized不满足时才考虑使用ReentrantLock。如图所示:
Java并发编程(8)---并发编程学习总结

代码地址:

https://github.com/XWxiaowei/ConcurrencyDemo