Java多线程控制

来源:互联网 发布:淘宝联盟如何购买省钱 编辑:程序博客网 时间:2024/06/10 16:01
1.1:并发、并行及引起的问题
    这两个是非常容易混淆的概念,而对其理解是有必要的。为了理解这两个概念,我们来考察一下常见的词组和其背后的含义:并行计算 和 大并发程序。
  • 并行计算(Parallel Computing)
    指同时使用多种计算资源解决计算问题的过程,是提高计算机系统计算速度和处理能力的一种有效手段。它的基本思想是用多个处理器来协同求解同一问题,即将被求解的问题分解成若干个部分,各部分均由一个独立的处理机来并行计算。
    实现并行计算的方案:基于流水线的时间并行,需要多个处理资源类别;而基于多个处理机的空间并行,则是一定要多个处理机。从这个定义可以看出来,是要求有多个处理核心(即便不是同一个类型的处理核心)。
    并行计算本身是要求任务一定是在“同时”处理的,无论在任何一个时间点,都有多个任务在一起执行。
  • 大并发程序
    指短时间有大量请求需要处理的程序。请求处理并不要去同时返回,只要在客户能够容忍的时间范围内能够全部处理完就可以了。从客户的角度看,这一段时间内机器“同时”处理和很多请求。
    这个实际上并没有要求执行具体是怎么完成的,是串行一个个执行还是并行多机处理,或者是每个任务分片交替处理。没有具体的要求。
    从这个具体的词汇可以看出,并发并不一定要求并行。但是实际上,当前的计算机配置都是多核的,而且现代CPU都是基于CPU时间片交替执行来获取宏观感觉上的“并行”执行的效果。因此,并发在通常也就是并行执行的。
    通过这两个词的场景可以看出,并发是真正的任何时间点的一同执行。而并发,则可以是以交替执行为实现方式的“伪并行”。不管是那种情况,都是多个任务在同时执行的,只不过并发中每个线程是不用暂停的,而并发可能出现暂停。
    所以,在我们的语境中,不明确区分这两者,或者直接说并发,因为并行实际上是并发的一种情况。
    从程序的角度来看,我们关注多个任务同时执行就可以了,不需要太过于考虑底层是否在分片轮转。
  • 并发带来的问题竞争条件
     进程P1,P2共用一个变量COUNT,初始值为0.
     先说明基本操作: Count++ 操作实际上并不是原子操作, 而分为三步, 将变量值读入内存寄存器 R1, 寄存器值自增1, 将R1再写回主存.
顺序执行, 
R1:=Count -> R1++ -> Count:=R1 -> R2:=Count -> R2++ -> Count:=R2  

最终 Count = 2

顺序执行的过程很容易理解, 变量的变化也在期待之中
在这里, 由于并发执行, 涉及到了线程的切换

R1:=Count -> R2:=Count -> R1++ ->  R2++ -> Count:=R1 -> Count:=R2  

最终 Count = 1

这里相当于 将 R1和 R2都设置成了初始值, 然后分别自增, 再写回主存. 所以最终结果跟期待的并不一致
    因为P1,P2两个进程的执行顺序是随机的(无论是单核还是多核都一样)。不同的执行顺序,COUNT的值会不同,这是不允许的。
像这种情况,及多个进程并发访问和操作同一数据且执行结果与访问发生的特定顺序有关,称为竞争条件。
    由于类似这样的问题,当一个数据被两个或以上线程共享,其中至少有一个需要对数据进行修改的时候,这时,就可能出现竞争条件。
    而最直接也是最容易想到的办法,就是对共享的数据或者对共享数据进行修改的程序段进行加锁处理。加锁的出发点是,让数据一次只被一个线程操作,直到操作完成

    加锁的代价相对较大,所以不是每次CPU分片切换的时候,休眠的线程都会释放锁。这相当于线程执行过程中,只要这个线程不释放锁,其对某个对象的锁是持续有效的。这也就是为什么,我们也不需要太考虑线程是否真的是并行执行还是分片轮转,即使在分片轮转中,线程对数据的所有权也是保持的,跟并行完全一样(只是没有同时执行指令而已)。而锁的存在,是考虑多线程对数据的控制,而不是指令相互影响。


1.2: Synchronized & Lock
    Java中常见的锁类型有两种:Synchronized & ReentrantLock。用法应该都知道,这里只谈异同和场景。

    reentrant 锁意味着什么呢?简单来说,它有一个与锁相关的获取计数器,如果拥有锁的某个线程再次得到锁,那么获取计数器就加1,然后锁需要被释放两次才能获得真正释放。这模仿了 synchronized 的语义;如果线程进入由线程已经拥有的监控器保护的 synchronized 块,就允许线程继续进行,当线程退出第二个(或者后续) synchronized 块的时候,不释放锁,只有线程退出它进入的监控器保护的第一个 synchronized 块时,才释放锁。
   
    两者的异同点:
    1)synchronized是在JVM层面上实现的,不但可以通过一些监控工具监控synchronized的锁定,而且在代码执行时出现异常,JVM会自动释放锁定,但是使用Lock则不行,lock是通过代码实现的,要保证锁定一定会被释放,就必须将unLock()放到finally{}中
    2)在资源竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态
    3)ReentrantLock可以实现非常细粒度的控制。lock不再依附于任何调用方法的对象,我们甚至可以让两个对象共享同一个lock!也可以让一个对象占有多个lock!! 。此外还有:
        a) lock(), 如果获取了锁立即返回,如果别的线程持有锁,当前线程则一直处于休眠状态,直到获取锁。这个跟Synchronized一样。
        b) tryLock(), 如果获取了锁立即返回true,如果别的线程正持有锁,立即返回false;
        c) 带时间限制的,tryLock(long timeout,TimeUnit unit),如果获取了锁定立即返回true,如果别的线程正持有锁,会等待参数给定的时间,在等待的过程中,如果获取了锁定,就返回true,如果等待超时,返回false;使用ReentrantLock,如果A不释放,可以使B在等待了足够长的时间以后,中断等待,而干别的事情。如果使用 synchronized ,如果A不释放,B将一直等下去,不能被中断,这就是优越的地方。
        d) lockInterruptibly:如果获取了锁定立即返回,如果没有获取锁定,当前线程处于休眠状态,直到或者锁定,或者当前线程被别的线程中断。
    4)使用Lock接口,是一种明确的加锁机制,之前我们的加锁是我们无法掌握的,我们无法知道是哪个线程的哪个方法获得锁,但能确保同一时间只有一个线程的一个方法获得锁。现在我们可以明确得的把握这个过程,灵活的设置lock scope,将一些耗时和具有线程安全性的代码移出lock scope。
     Synchronized无法知道lock被递归调用的次数,但是使用ReentrantLock可以做到这点。我们可以通过getHoldCount()方法来获得当前线程对lock所要求的数量,如果数量为0,代表当前线程并未持有锁,但是还不能知道锁是自由的,我们必须通过isLocked()来判断。我们还可以通过isHeldByCurrentThread()来判断lock是否由当前的线程所持有,getQueueLength()可以用来取得有多少个线程在等待取得该锁,但这个只是预估值。

    看起来 ReentrantLock 无论在哪方面都比 synchronized 好 —— 所有 synchronized 能做的,它都能做,它拥有与 synchronized 相同的内存和并发性语义,还拥有 synchronized 所没有的特性,在负荷下还拥有更好的性能。
    但是一般来说,除非您对 Lock 的某个高级特性有明确的需要,或者有明确的证据(而不是仅仅是怀疑)表明在特定情况下,同步已经成为可伸缩性的瓶颈,否则还是应当继续使用 synchronized。
    因为synchronized 仍然有一些优势。比如,在使用 synchronized 的时候,不能忘记释放锁;在退出 synchronized 块时,JVM 会为您做这件事。您很容易忘记用 finally 块释放锁,这对程序非常有害。程序能够通过测试,但会在实际工作中出现死锁,那时会很难指出原因(这也是为什么根本不让初级开发人员使用 Lock 的一个好理由)。
    另一个原因是因为,当 JVM 用 synchronized 管理锁定请求和释放时,JVM 在生成线程转储时能够包括锁定信息。这些对调试非常有价值,因为它们能标识死锁或者其他异常行为的来源。Lock 类只是普通的类,JVM 不知道具体哪个线程拥有 Lock 对象。
    而且,几乎每个开发人员都熟悉 synchronized,它可以在 JVM 的所有版本中工作。编译程序通常会尽可能的进行优化synchronize,另外可读性非常好。
    
1.3:ThreadLocal,Volatile,CAS
  • 锁(lock)的代价
     锁是用来做并发最简单的方式,当然其代价也是最高的。
     内核态的锁的时候需要操作系统进行一次上下文切换,加锁、释放锁会导致比较多的上下文切换和调度延时,等待锁的线程会被挂起直至锁释放。在上下文切换的时候,cpu之前缓存的指令和数据都将失效,对性能有很大的损失。
     这种通过使用一致的锁定协议来协调对共享状态的访问,可以确保无论哪个线程持有守护变量的锁,都采用独占的方式来访问这些变量. 如果出现多个线程同时访问锁,那第一些线线程将被挂起,当线程恢复执行时,必须等待其它线程执行完他们的时间片以后才能被调度执行,在挂起和恢复执行过程中存在着很大的开销。
     锁还存在着其它一些缺点,当一个线程正在等待锁时,它不能做任何事。如果一个线程在持有锁的情况下被延迟执行,那么所有需要这个锁的线程都无法执行下去。如果被阻塞的线程优先级高,而持有锁的线程优先级低,将会导致优先级反转(Priority Inversion)。
    所以,如果能不加锁还是尽量不加。

  • ThreadLocal
    JDK源码中这样描述ThreadLocal:
    ThreadLocal类用来提供线程内部的局部变量。这种变量在多线程环境下访问(通过get或set方法访问)时能保证各个线程里的变量相对独立于其他线程内的变量。ThreadLocal实例通常来说都是private static类型的,用于关联线程和线程的上下文。通常这样的private static类型都会有线程同步问题。但是在特定应用场景下,利用ThreadLocal来避免进行锁操作。    
    通常对于常用的业务逻辑有两种处理方法:包装成业务逻辑类,每次都进行New出一个新对象,业务逻辑封装成静态方法将必备参数传入进行调用,这就是完全无状态的Bean。
然而,第一种方式带有很多的新建,对应的GC也很多,使用也相对麻烦。而第二种,则能够直接调用,但是需要传递很多参数,线程敏感的任何参数都不能从静态类中获取,这就导致了很多业务难以实现。
     当线程内部需要维持的变量很少的时候(例如一个全局的Serial),为了这些很少的变量而每次都新建业务Bean是很不合算的,而每次调用方法都传递这些参数有很繁琐。那么就需要找到一个办法在线程的生命周期内保持这个变量,这就是ThreadLocal存在的价值:在单例Bean中保持线程局部变量。
    因此,ThreadLocal的应用场合,最适合的是按多线程多实例(每个线程对应一个实例)的对象的访问,并且这个对象很多地方都要用到。 
    在Spring中,绝大部分Bean都可以声明为singleton作用域。就是因为Spring对一些Bean(如RequestContextHolder、TransactionSynchronizationManager、LocaleContextHolder等)中非线程安全状态采用ThreadLocal进行处理,让它们也成为线程安全的状态,因为有状态的Bean就可以在多线程中共享了。    

  • Java中的原子操作( atomic operations) 
    原子操作指的是在一步之内就完成而且不能被中断。原子操作在多线程环境中是线程安全的,无需考虑同步的问题。在java中,下列操作是原子操作:
        1)基本类型的变量赋值都是原子操作, 除了 long和double.
        2)所以应用的赋值, 即对象赋值
        3)java.concurrent.Atomic* 所有的类自带操作
        4)所有的 volatile 修饰过的 long 和 double 的赋值操作
    问题来了,为什么long型赋值不是原子操作呢?例如:
        long foo = 65465498L;
        实际上java会分两步写入这个long变量,先写32位,再写后32位。这样就线程不安全了。

  • Vloatile
     与锁相比,volatile变量是一和更轻量级的同步机制,因为在使用这些变量时不会发生上下文切换和线程调度等操作,但是volatile变量也存在一些局限:不能用于构建原子的复合操作,因此当一个变量依赖旧值时就不能使用volatile变量。

当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存变量的具体值load到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。
这样在堆中的对象的值就产生变化了。上描述这写交互:
read and load 从主存复制变量到当前工作内存
use and assign  执行代码,改变共享变量值 
store and write 用工作内存数据刷新主存相关内容
其中use and assign 可以多次出现
是这一些操作并不是原子性,也就是 在read load之后,如果主内存count变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,所以计算出来的结果会和预期不一样。
     现在来解释,为什么 long和double 如果改成下面的就线程安全了
        private volatile long foo;
    因为虽然 long 的赋值分为两步, 但是只要多线程赋值时操作的是同一块主内存, 那么只要一个改了, 其他都能看到改过了。其他线程的修改也是从高位开始的,只要一个改了其他都会在改的基础上再改的。低位的情况类似。

    但是对于volatile修饰的变量,jvm虚拟机只是保证从主内存加载到线程工作内存的值是最新的, 每次修改都直接反映到主内存中。也就是说还是可能出现竞争条件。
    例如假如线程1,线程2 在进行read, load 操作中,发现主内存中count的值都是5,那么都会加载这个最新的值在线程1堆count进行修改之后write到主内存中,主内存中的count变量就会变为6。线程2由于已经进行read,load操作,在进行运算之后,也会更新主内存count的变量值为6。导致两个线程及时用volatile关键字修改之后,还是会存在并发的情况。

  • CAS, 无锁算法
    要实现无锁(lock-free)的非阻塞算法有多种实现方法,其中 CAS(比较与交换,Compare and swap) 是一种有名的无锁算法。在说明CAS之前,先介绍乐观锁。
    独占锁是一种悲观锁,synchronized就是一种独占锁,它假设最坏的情况,并且只有在确保其它线程不会造成干扰的情况下执行,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。 典型的比如 DB 中的 Version 控制字段。     
    
     CAS, CPU指令,在大多数处理器架构,包括IA32、Space中采用的都是CAS指令,CAS的语义是“我认为V的值应该为A,如果是,那么将V的值更新为B,否则不修改并告诉V的值实际为多少”,CAS是种 乐观锁 技术。
     当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
     CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
     容易看出 CAS 操作是基于共享数据不会被修改的假设,采用了类似于数据库的 commit-retry 的模式。当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。
     在JDK1.5之前,如果不编写明确的代码就无法执行CAS操作,在JDK1.5中引入了底层的支持,在int、long和对象的引用等类型上都公开了CAS的操作(即AtomicXXX类),并且JVM把它们编译为底层硬件提供的最有效的方法,在运行CAS的平台上,运行时把它们编译为相应的机器指令,如果处理器/CPU不支持CAS指令,那么JVM将使用自旋锁。因此,值得注意的是, CAS解决方案与平台/编译器紧密相关 。
     在原子类变量中,如java.util.concurrent.atomic中的AtomicXXX,都使用了这些底层的JVM支持为数字类型的引用类型提供一种高效的CAS操作,而在java.util.concurrent中的大多数类在实现时都直接或间接的使用了这些原子变量类。
     从源码看AtomicLong.incrementAndGet的实现用了乐观锁技术,调用了 sun.misc.Unsafe 类库里面的 CAS算法,用CPU指令来实现无锁自增。所以,AtomicLong.incrementAndGet的自增比用synchronized的锁效率倍增。其他了 Atomic 实现方式是类似的. 
     
  • CAS的高效与失败开销(CPU Cache Miss problem)
     前面说过了,CAS(比较并交换)是CPU指令级的操作,只有一步原子操作,所以非常快。而且CAS避免了请求操作系统来裁定锁的问题,不用麻烦操作系统,直接在CPU内部就搞定了。
     但CAS就没有开销了吗?不!有cache miss的情况。这个问题比较复杂,首先需要了解CPU的硬件体系结构:
                   
     上图可以看到一个8核CPU计算机系统,每个CPU有cache(CPU内部的高速缓存,寄存器),管芯内还带有一个互联模块,使管芯内的两个核可以互相通信。
     在图中央的系统互联模块可以让四个管芯相互通信,并且将管芯与主存连接起来。
     数据以“缓存线”为单位在系统中传输,“缓存线”对应于内存中一个 2 的幂大小的字节块,大小通常为 32 到 256 字节之间。当 CPU 从内存中读取一个变量到它的寄存器中时,必须首先将包含了该变量的缓存线读取到 CPU 高速缓存。同样地,CPU 将寄存器中的一个值存储到内存时,不仅必须将包含了该值的缓存线读到 CPU 高速缓存,还必须确保没有其他 CPU 拥有该缓存线的拷贝。
     比如,如果 CPU0 在对一个变量执行“比较并交换”(CAS)操作,而该变量所在的缓存线在 CPU7 的高速缓存中,就会发生以下经过简化的事件序列:
            CPU0 检查本地高速缓存,没有找到缓存线。 
            请求被转发到 CPU0 和 CPU1 的互联模块,检查 CPU1 的本地高速缓存,没有找到缓存线。
            请求被转发到系统互联模块,检查其他三个管芯,得知缓存线被 CPU6和 CPU7 所在的管芯持有。
            请求被转发到 CPU6 和 CPU7 的互联模块,检查这两个 CPU 的高速缓存,在 CPU7 的高速缓存中找到缓存线。
            CPU7 将缓存线发送给所属的互联模块,并且刷新自己高速缓存中的缓存线。
            CPU6 和 CPU7 的互联模块将缓存线发送给系统互联模块。
            系统互联模块将缓存线发送给 CPU0 和 CPU1 的互联模块。
            CPU0 和 CPU1 的互联模块将缓存线发送给 CPU0 的高速缓存。
    CPU0 现在可以对高速缓存中的变量执行 CAS 操作了,以上是刷新不同CPU缓存的开销。
     最好情况下的 CAS 操作消耗大概 40 纳秒,超过 60 个时钟周期。这里的“最好情况”是指对某一个变量执行 CAS 操作的 CPU 正好是最后一个操作该变量的CPU,所以对应的缓存线已经在 CPU 的高速缓存中了.
     类似地,最好情况下的锁操作(一个“round trip 对”包括获取锁和随后的释放锁)消耗超过 60 纳秒,超过 100 个时钟周期。这里的“最好情况”意味着用于表示锁的数据结构已经在获取和释放锁的 CPU 所属的高速缓存中了。 锁操作比 CAS 操作更加耗时,是因深入理解并行编程, 因为锁操作的数据结构中需要两个原子操作。
     缓存未命中消耗大概 140 纳秒,超过 200 个时钟周期。需要在存储新值时查询变量的旧值的 CAS 操作,消耗大概 300 纳秒,超过 500 个时钟周期。想想这个,在执行一次 CAS 操作的时间里,CPU 可以执行 500 条普通指令。这表明了细粒度锁的局限性。
     以下是cache miss cas 和lock的性能对比:
     
    所以,在竞争特别大的时候使用CAS不一定能够优化性能。


2:线程协作: wait&notify, Condition
    在多线程执行过程中,除了控制竞争条件防止同时执行之外,还有一种是需要控制线程之间执行次序的,这就是线程的协作。
    比如说最经典的生产者-消费者模型:当队列满时,生产者需要等待队列有空间才能继续往里面放入商品,而在等待的期间内,生产者必须释放对临界资源(即队列)的占用权。然后等待消费者消费了商品,然后消费者通知生产者队列有空间了。同样地,当队列空时,消费者也必须等待,等待生产者通知它队列中有商品了。这种互相通信的过程就是一种线程间的协作。
     Java中线程协作的最常见的两种方式:利用Object.wait()、Object.notify()和使用Condition。
  •  wait()、notify()和notifyAll() ,配合 synchronized使用
        1)wait()、notify()和notifyAll()方法是本地方法,并且为final方法,无法被重写。
        2)调用某个对象的wait()方法能让当前线程阻塞,能够执行表示当前线程是持有当前对象的锁,那么wait时会交出此对象的锁,然后进入等待状态。阻塞和Thread类中的sleep虽然都是暂停当前线程的执行,但sleep它并不释放对象锁。
        3)调用某个对象的notify()方法能够唤醒一个在该对象上执行过wait()的线程,如果有多个线程等待则只能唤醒其中一个线程, 具体唤醒哪个线程则是随机的。
            调用某个对象的notify()方法,当前线程必须拥有这个对象的锁,因此调用notify()方法必须在同步块或者同步方法中进行(synchronized块或者synchronized方法)。
            一个线程被唤醒不代表立即获取了对象的锁,只有等调用完notify()或者notifyAll()的线程退出synchronized块,释放对象锁后,被唤醒的线程才可获得锁执行。
        4)调用notifyAll()方法能够唤醒所有正在这个对象上等待的线程。当有多个消费者在等待空队列的时候,notifyAll() 是有意义的。
    有朋友可能会有疑问:为何这三个不是Thread类声明中的方法,而是Object类中声明的方法(当然由于Thread类继承了Object类,所以Thread也可以调用者三个方法)?其实这个问题很简单,由于锁本身是依赖在对象上的,当然应该通过这个对象来操作了。当前线程可能会等待多个线程的锁,如果通过线程来操作,那么非常不符合实际模型。

  • Condition,配合Lock使用
     Condition是在java 1.5中才出现的,相比使用Object的wait()、notify(),使用Condition的await()、signal()这种方式实现线程间协作更加安全和高效,因此通常来说比较推荐使用Condition。JDK中的阻塞队列实际上是使用了Condition来模拟线程间协作。
     Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition(). 调用Condition的await()和signal()方法,都必须在lock保护之内,就是说必须在lock.lock()和lock.unlock之间才可以使用。
    Conditon中的await()对应Object的wait();
    Condition中的signal()对应Object的notify();
    Condition中的signalAll()对应Object的notifyAll()。


参考:
http://zzhonghe.iteye.com/blog/826162 各种同步方法性能比较(synchronized,ReentrantLock,Atomic)
http://houlinyan.iteye.com/blog/1112535 Lock与synchronized 的区别
《Java高并发程序设计》葛一鸣
《Java程序性能优化》葛一鸣
http://www.cnblogs.com/dolphin0520/p/3920385.html Java并发编程:线程间协作的两种方式:wait、notify、notifyAll和Condition
https://www.ibm.com/developerworks/cn/java/j-jtp10264/index.html JDK 5.0 中更灵活、更具可伸缩性的锁定机制
http://www.cnblogs.com/Mainz/p/3546347.html?utm_source=tuicool&utm_medium=referral 非阻塞同步算法与CAS(Compare and Swap)无锁算法