本文首发于微信公众号——世界上有意思的事,搬运转载请注明出处,否则将追究版权责任。微信号:a1018998632,交流qq群:859640274
终于把这本经典的Java并发书看完了,虽然之前看的Thinking in Java和Effective Java里面都有并发的章节,但是这本书讲的更加深入,并发是Java程序员抛不开的一个话题,所以看一看这本书对我们是极其有帮助的。当然这本书写了挺久的,里面有些东西可能落伍了,比如说GUI编程。所以我认为用处不大的章节都选择性跳过了。还有就是在TIJ和EJ里面讲到过的内容也跳过了,没看过前面两本书的同学可以看看我略过的章节。最后就是有几个实战内容,感觉目前我的层次还达不到那么高,写起来可能体会不深就放一放,等工作了一段时间之后在回过头来看看,加深感悟。
线程安全性
1.什么是线程安全
- 1.当多线程访问某个类的时候,这个类始终表现出预期中正确的行为,那么这个类就可以被称为线程安全的类。
- 2.线程安全的类中封装了必要的同步机制,客户端在调用的时候不需要进一步采取同步措施
- 3.当一个类既不包含域又不包含任何其他类中域的引用可以称为无状态的类,这种类一定是线程安全的。
2.原子性
当一个对象被多线程使用了,而我们用一个int计算这个对象被调用了多少次,此时这个对象就是非线程安全。因为int的递增操作并不是原子性的,可能int在一个线程递增了一半,该对象就切换到了另一个线程中运行了,此时该对象就会产生与我们预期不符的行为。
- 1.竞态条件:当一个操作的正确性,取决于多个线程交替执行的时序的时候,这就叫竞态条件。如我们上面举的例子,int的递增的正确性取决于某个线程是否会在另一个线程操作到一半的时候就进行操作。常见的竞态条件就是先检查后执行,如某个线程的操作插入到另一个线程的检查和执行操作之间。
- 2.延迟初始化中的竞态条件:
- 1.延迟初始化是一种先检查后执行的竞态条件,如单例在多线程下在用到的时候才初始化:先检查单例是否为null,在初始化单例。
- 2.前面举的int递增的例子是另一种竞态条件:读取-修改-写入,只要在某个环节被调度到其他线程,类就会产生与预期不相符的行为。
- 3.复合操作:我们前面举的竞态条件,说白了就是一系列前后关联的操作————复合操作,一旦这一系列操作被打断,就是线程不安全。所以我们需要将这一系列操作通过java的机制改成原子操作。
3.加锁机制
我们都知道java中有原子变量,那么是不是说对于一个类,我们只需要把所有的域都变成原子变量,这个类就是线程安全的呢?很显然并不是,我们在2中提到了竞态条件,当一系列原子变量的操作是前后关联的,那么这一系列的操作就是竞态条件,此时我们如果不进行处理,那么这个类就不是线程安全的
- 1.内置锁:
- 1.每个对象和Class对象都有一个内置锁,一个锁只有一个线程能够持有。
- 2.对象锁用于非static方法,Class对象锁用于static方法。
- 3.线程1进入到synchronized块1中,线程1就持有了synchronized块1中传入的锁,线程1运行出synchronized块1就会释放锁,线程2如果运行到传入同样锁的synchronized块2,就会停下来等待线程1运行出synchronized块1。
- 4.说白了整个synchronized块就是一个原子操作,所以我们可以将竞态条件放入synchronized块中,这样一来该类这一系列操作就变成线程安全的了。
- 2.重入:当一个线程多次试图进入同一个锁的synchronized块会出现什么情况呢?
- 1.如果没有重入的话,那么该线程就将等待自己完成synchronized块中的操作,此时这个线程就会产生死锁(即该线程永远也出不了synchronized块,而且其他线程试图获取这个锁的时候也会被阻塞停止,整个程序就会崩溃)
- 2.重入即当一个线程获取了某个锁之后,在没有释放锁的前提下又获取锁是可行的。可以这样理解当锁无线程获取,内部计数为0,某个线程获取了,计数为1,若该线程重复获取该锁,获取一次计数加一,当该线程跑出一个synchronized块计数减一。
对象的共享
同步不仅能实现原子操作,同步还有一个重要的方面就是内存可见性的实现。内存可见性表示,在一个共享变量被修改了之后,其他线程能够立即观察到该变量的修改后的值。如果不同步的话这一点是做不到的。
1.可见性
- 1.首先我们需要知道的是,java的线程都有自己独立的缓存,线程之间进行共享变量的交互是通过自身和缓存和主存的交互实现的。
- 2.如果线程的每次更改缓存都刷入主存,主存每次被一个线程的缓存修改,都通知所有的线程刷新自身的缓存的话,那样就太不经济了。
- 3.由于1和2,就会产生一个现象:当线程1修改了一个共享变量之后,线程2获取的共享变量还是更改前的值。即线程1更改共享变量并没有刷入主存,或者线程2并没有去主存中获取到新的共享变量,或以上两者皆有
- 4.为了解决内存可见性我们可以使用volatile关键字和同步这两种方式
- 5.非原子的64为操作:虽然java要求变量的读和写都必须是原子操作,但是64位的long和double会被分为两次32位的存取操作。此时需要使用volatile关键字。
2.发布与逸出
- 1.发布:使得一个对象能被作用域以外的对象访问,比如将该对象的引用设为static或者在非private方法中返回该引用
- 2.逸出:由于发布一个对象的内部状态可能会破坏封装性和不变性导致线程不安全,在这种情况下被称为对象逸出
- 3.在发布某对象的时候可能会间接发布本不想发布的对象,如一个private的数组,一旦被发布,其中储存的对象也会被发布
- 4.如果在内部类中使用了外围类的方法,那么外围类也会被发布,这个被称为this逸出。例如在一个构造函数中运行一个线程,在该类还没构造完毕的时候,该类就对线程可见了,那么此时就会出现 构造还没完成就发布对象的问题 。这个问题会导致线程不安全。
3.线程封闭
- 1.线程封闭是指,对象被封闭在一个线程中,这样一来就不需要对对象进行同步了。
- 2.像Android中所有的view更新的操作都要在主线程进行,我们可以说View对象是线程封闭的对象。
- 3.Ad-hoc线程封闭:没有语言特性保证,只能是程序员自己保证。如volatile,只要保证没有其他线程对其进行写操作,那么就能保证本线程对其写操作会会通知到别的线程
- 4.栈封闭:局部变量只要不发布到其他线程中,就是栈封闭的。、
- 5.ThreadLocal:每个线程都会有一个变量的不同版本,内部实现类似于Map<Thread,T>。
4.不变性
- 1.不可变对象一定是线程安全的
- 2.不可变对象满足下列条件:
- 1.所有域是final的,域内部的域也是final的
- 2.所有域不可改变
- 3.this没有在构造的时候逸出
5.安全发布
- 1.如果仅仅将对象引用保存在public域之中,并不算安全发布,因为可见性问题,该对象可能在其他线程是没有构建好的
- 2.正确的对象被破坏:当如1一样发布一个对象的时候,会有线程1在使用该对象中途,线程2改变该对象的状态,使得线程1抛出异常。
- 3.不可变对象与初始化安全性:如果1和2中的对象是不可变的,那么就不会出现2的情况了。
- 4.安全的发布可变对象:要安全的发布一个可变对象,需要使得对象的引用和状态同时对其他所有的线程可见,有以下几种方式
- 1.在静态初始化对象引用,因为JVM的类加载过程中是同步的
- 2.对对象引用使用volatile或AtomicReference
- 3.将对象引用放入final域中
- 4.对对象引用加锁
- 5.安全地共享对象:在发布一个对象的时候需要明确指出该对象的多线程共享规则:
- 1.是线程封闭?:只能由一个线程拥有
- 2.是只读共享?:只能并发读
- 3.是线程安全共享?:类内部实现了同步,可以随意使用
- 4.是保护对象?:类内部没有实现同步,需要使用者在外部同步
对象组合
1.设计线程安全的类
- 1.如何判断一个类是否是线程安全的?
- 1.找出构成对象状态的所有变量:即该对象所有会变的域
- 2.找出约束对象状态变量的不变性条件:即所有状态变量变化的区域
- 3.建立对象状态的并发访问管理策略:即根据对所有状态变量建立同步策略
- 2.收集同步需求:比如变量的范围,比如变量当前的状态是否和之前的状态有关等等
2.实例封闭
- 1.当已知某个非线程安全的对象的所有调用路径的时候,可以将其封装在一个线程安全的类中使用
- 2.java监视器模式:1就是这个模式,将所有可变对象都封装起来,使用自身的锁来保护可变对象。HashTable就是这样实现的,但是这只是简单的粗粒度封装,但是如果要提供性能,需要进行细粒度封装。除了使用内置锁,还能使用私有对象锁,这样能让客户端获取不到保护可变对象的锁,但是又能让客户端通过公有方法使用它。
3.线程安全性的委托
- 1.可以通过委托机制,将线程安全性质委托给线程安全类,如ConcurrentHashMap
- 2.当委托失效的时候:如果一个类中多个线程安全对象中有复合的不变性条件的话,那么还是得在类中进行同步
基础构建模块
1.同步容器类
- Vector和HashTable都是早期的同步类。
- 1.同步容器类的问题:有些符合操作可能会在其他元素并发修改的时候出问题如:迭代、跳转(找到当前元素的下一个元素)和条件运算(若没有则添加)。
- 1.对Vector多线程进行getLast()和deleteLast()的时候,由于这两个方法不是同步的,所以可能会出现在getLast()中间插入deleteLast()操作,导致数组边界异常
- 2.为了解决1的问题,可以对这两个操作加上锁
- 3.同样在对Vector进行迭代的时候,也会出现1中的问题。也需要加锁
- 2.迭代器和ConcurrentModificationException:由于一些并发容器并没有对 在迭代期间容器进行修改 的情况进行设想,所以他们采用了“即时失败”的策略,即迭代期间如果容器被修改了,那么就抛出ConcurrentModificationException。
- 1.为了在迭代期间不抛出异常,可以对整个迭代进行加锁
- 2.如果在迭代期间进行加锁了,一旦容器规模比较大,就会出现性能问题。
- 3.如果不希望在迭代期间进行容器加锁,可以采取克隆的方式,将克隆出来的容器封闭在本地,对克隆的容器进行迭代,这样就不会出现问题了。
- 3.隐藏迭代器:为了在迭代的时候抛出异常,我们会选择在所有的迭代中进行加锁,但是有些情况下我们没有进行迭代,而java类库实现的时候会对容器进行迭代。如容器的toString()方法,这样一来还是会抛出异常。
2.并发容器
- java1.5提供了并发容器来代替同步容器,增强了性能,也提供了常用的同步复合操作,避免了Vector中的情况
- 1.ConcurrentHashMap:不是采用HashTable的加锁方式,采用的是分段锁,并发迭代修改期间不加锁也不会抛出异常。一般使用这个并发,只有要加锁独占Map的时候才放弃他。
- 2.额外的原子Map操作:由于ConcurrentHashMap不能被客户端加锁独占,所以客户端不能创建新的原子操作,但是一些常用的复合操作,ConcurrentMap中已经实现了
- 3.CopyOnWriteArrayList
3.阻塞队列和生产者消费者模式
- 各种BlockingQueue的使用
4.阻塞方法和中断方法
- 1.当io、等待锁、sleep和wait的时候线程会被阻塞挂起。
- 2.如果一个方法会抛出一个InterruptedException表示这个方法是一个阻塞的方法,如果这个方法被中断,那么其会被尽快结束执行
- 3.Thread提供了interrupt方法,方便查询线程是否被中断了。
- 4.当在一个线程中抛出一个中断异常的时候,有两种选择:
- 1.向上抛出异常
- 2.如果在Runnable中的话,已经不能抛出异常了,此时需要捕获这个异常,然后可以停止线程,也可以通过interrupt方法恢复中断
5.同步工具
- 1.闭锁:在闭锁状态结束之前,其他所有的线程都不能进行操作,直至闭锁结束。
- 1.例如一个线程完毕之后,其他依赖这个线程的线程才运行
- 2.CountDownLatch:使得多个线程等待一组事件的发生,其中有一个计数器,表示还剩多少事件。调用await的线程会阻塞到计数器为0的时候
- 2.FutureTask:调用get的线程,会阻塞到Callable的结果产生。
- 3.信号量:
- 4.栏栅:CyclicBarrier
6.构建高效可伸缩的结果缓存
- 实战内容,在项目中有涉猎,以后再看
任务执行
1.在线程中执行任务
- 1.串行执行任务,太浪费cpu
- 2.为每个任务开辟线程,太浪费资源
2.Executor框架
- 1.Executor是一个接口
- 2.基于生产者消费者模式,提交任务为生产者,执行任务的线程为消费者
- 3.线程池:Executors提供了一系列线程池;
- 4.Executor的生命周期:jvm只有在所有非守护线程退出之后才会退出,所以终止Executors是个问题
- 1.Executor继承了ExecutorService,其中有一些管理生命周期的方法
- 2.ExecutorService有三种状态:运行、关闭、终止。由于Executor中的任务是异步执行的,在某个时刻可能有些任务被放在任务队列里没有执行,有些则正在执行。
- 3.shutdwon方法调用后,表示Executor已关闭不再接受新任务,但是以前的任务运行完毕之后才会变成终止状态
- 4.shutdwonNow方法调用后则是:直接变成终止状态,无论是运行还是没运行的任务都会被取消
取消与关闭
1.任务取消
- 1.如果是一个循环任务,那么可以在条件中加上一个flag,当flag为否的时候退出循环
- 2.在有些情况下如果循环任务中调用了一个阻塞方法,那么可能要花费一定时间才能退出,甚至一直无法退出
- 3.在2的情况下,我们可以使用中断来将线程终结
- 1.在线程1调用线程2的中断表示:线程1希望线程2在适合的情况下停止当前工作(注意线程2不是立即停下来,即非抢占式)
- 2.对于阻塞库中的方法如sleep、wait,会在调用前检查该线程是否被中断,如果中断那么就会清除中断,抛出InterruptedException,我们只需要在里面取消阻塞操作即可取消任务。
- 3.如果线程处于非中断状态,如一直while循环,那么可以在while条件中判断是否产生中断,若产生就退出循环
- 4.通过interrupt可以将中断状态取消,如果在捕获到异常后希望继续进行别的阻塞库中的操作,可以使用这个
- 5.Future可以通过cancel来取消
- 6.同步io、socket io、异步io,这几个情况虽然是阻塞方法,但是在线程中断的时候并不会抛出InterruptedException,但是我们可以通过让这些方法抛出异常来达到同样的效果,如关闭socket,关闭流等等
- 7.当一个线程在获取锁,此时用上面任意的方法都不能取消任务,此时可以使用Lock#lockInterruptibly
- 8.我们可以将6中的方式封装成非标准的取消任务的方式。
2.停止基于线程的服务
- 1.只要线程存在的时间大于线程创建的时间,就必须为其提供生命周期的方法,如停止线程
- 2.关闭生产者-消费者的一种方式是:"毒丸"对象,即一旦消费者获取了 某个特定的对象,那么就表示处理可以停止,消费者线程可以关闭了
- 3.shutdownNow的局限性:当强制关闭ExecutorService的时候,我们无法知道哪些任务正在运行,哪些任务还没运行。如果我们想保存此时的状态就无从下手此时我们可以实现AbstractExecutorService,然后将具体操作委托给一个ExecutorService,但是在实现中记录在shutdownNow时,还没执行的任务。
3.处理非正常的线程终止
- 1.一个线程在抛出异常之后,如果没进行处理就会被终止,虽然会报异常,但是我们通常很难被发觉
- 2.解决1的一个方法是,在整个线程的最外面catch异常,然后处理异常。这种方式比直接结束整个程序好一点,但是有安全性问题因为这里抛出异常的时候可能整个程序会受影响。
- 3.我们可以主动检测异常:通过实现Thread.UncaughtExceptionHandler接口,将未捕获的异常写入异常log之中,或者进行其他恢复性的操作
- 4.只有通过execute提交任务,才会进行3中的方式。如果通过submit,那么程序会认为异常是返回的一部分,如用submit执行一个Future
4.JVM关闭
- 1.关闭钩子:通过Runtime.addShutdownHook注册的一系列清理线程将会被调用进行资源的清理。
- 1.如果此时还有线程在执行,那么所有线程会并发执行。
- 2.当所有钩子线程执行完毕,jvm会运行终结器。
- 3.如果终结器或钩子线程没有执行完,那么关闭进程将会被挂起,此时jvm需要被强行关闭
- 4.jvm被强行关闭时,应用线程会被强行结束,但是钩子线程不会被关闭
- 2.守护线程:这些线程不会影响jvm关闭
避免活跃性危险
1.死锁
- 1.哲学家就餐问题:
- 1.互斥条件:一个资源每次只能被一个进程使用。
- 2.请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 3.不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
- 4.循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
2.死锁的避免和诊断
- 1.支持定时锁:使用Lock代替内置锁,可以指定一个获取锁等待的时限
- 2.通过线程转储来分析死锁
3.其他活跃性危险
- 1.饥饿:一个线程很长时间访问不到其需要的资源,如对线程优先级设置不对,导致一个线程很长时间获取不到cpu时间片
- 2.活锁
性能与可伸缩性
1.线程引入的开销
- 1.上下文切换:线程被阻塞会被JVM挂起,如果经常阻塞则无法完整调度时间片,从而增加上下文切换的时间
- 2.内存同步:使用synchronized或者volatile关键字会将所有线程的本地缓存刷新,此时会消耗时间
- 3.阻塞:线程被阻塞会被挂起,此时就会多了两个上下文切换的时间,所以少阻塞
2.减少锁的竞争
- 三种方法降低锁的竞争程度:1.减少锁持有时间 2.降低锁请求频率 3.使用可协调的独占锁
- 1.缩小锁范围:将临界区的代码数量降到最小,尤其是io操作等会阻塞线程的操作
- 2.减小锁的粒度:减少线程请求同一把锁的频率,也就是将锁分解成多个锁
- 3.锁分段:将一个竞争激烈的锁分成多个锁,可能还是会竞争很激烈。将一组独立对象上的锁进行分解,被称为锁分段,如将一个Map桶让16个锁保护一个锁保护N/16个桶,那么并发写的性能就能提升16倍。这样的坏处就是:独占访问需要获取多个锁,更困难,开销更大
- 4.避免热点域?
- 5.一些代替独占锁的方式:并发容器、读写锁、不可变对象和原子变量
显式锁
1.Lock和ReentrantLock
- 1.Lock提供了可轮询、定时以及中断锁获取的功能,其他方面和内置锁类似,需要在try final里面释放锁
- 2.轮询锁和定时锁:可以通过tryLock来轮询避免,也可以通过定时锁来避免死锁
2.公平性
- 1.创建ReentrantLock的时候,可以设置锁的公平性,默认是非公平锁
- 1.公平锁:按照线程排队的先后获取锁
- 2.非公平锁:一个线程要获取锁,但还没放入请求锁的队列,此时锁可以用了,那么这个线程就可以插队
- 2.内置锁和Lock之间的选择:内置锁简洁,Lock可以作为高级工具使用
3.读写锁
- 1.不对读读进行加锁,适用于大量读的操作
原子变量与非阻塞同步机制
1.锁的劣势
- 1.一个线程因为锁的挂起和唤醒开销比较大。
- 2.volatile是一种轻量级同步机制,但是如果需要依赖旧值,就会出现同步错误
2.硬件对并发的支持
- 1.独占锁是一种悲观锁,对于细粒度操作可以采用乐观的方式:在更新过程中判读是否有其他线程干扰,如果有这个操作就失败,而不是拒绝这个操作。
- 2.比较交换:CAS指令可以检测其他线程的干扰,使得不用锁也可以实现原子的读-改-写操作
- 3.java1.5后在底层实现了CAS操作,一些原子变量就是用的这个机制
3.原子变量类
- 1.原子变量是一种更好的volatile
- 2.原子变量比锁的性能更好
4.非阻塞算法
- 实战,以后看
什么是内存模型
- 1.每个线程有自己的本地缓存,本地缓存有各个共享变量的副本,所有线程的缓存都会和主村进行双向通信,但不是实时的。
- 2.为了让更多指令进行并发,在字节码编译和字节码转指令的时候会进行指令重排,也就是说没有必然先后关系的代码,最终运行的时候先后顺序是不一定的。
- 3.代码的先后顺序有一个原则:Happens-Before
- 1.程序顺序规则:程序中A在B前面,线程中A在B前面
- 2.监视器锁规则:监视器锁的解锁必须在同一监视器锁加锁之前
- 3.volatile规则:volatile变量写入操作必须在对该变量读操作之前
- 4.线程启动规则:Thread#start()必须在该线程中任何操作之前
- 5.线程结束规则:线程中所有操作都要在其他线程检测到该线程结束之前执行
- 6.终结器规则:对象构造函数必须在启动该对象的终结器之前完成
- 7.中断规则:线程1调用线程2的中断,必须在中断线程检测interrupt之前执行
- 8.传递性:A在B前面,B在C前面,那么A在C前面
不贩卖焦虑,也不标题党。分享一些这个世界上有意思的事情。题材包括且不限于:科幻、科学、科技、互联网、程序员、计算机编程。下面是我的微信公众号:世界上有意思的事,干货多多等你来看。
网友评论