java特种兵读书笔记(5-7)——编写并发程序要注意什么

常见问题


Vector和CopyOnWriteArrayList是线程安全的,是说它的多线程发生单次读写不会出问题,但不代表两次读写操作过程中没有任何其他的线程写入数据。

Atomic实现了线程安全操作,但不代表对Atomic多次写入过程中没有其他线程写入。

线程池和各种队列很好用,但是使用不当也会导致各种问题。

synchronized锁粒度问题


一个人进入食堂吃饭时要锁住整个食堂大门,一个人进入小区之后不允许其他人进入该小区,这都是不合理的粒度,会浪费很多资源。

这样需要细化:

①首先按照读写分开的思路。

比如对房子来说,读操作可以是人去看房子,写操作是施工队对房子施工。如果有人看房子可以随便看,也可以多人一起看,但是如果施工队要施工,就无法看房子了。CopyOnWrite类似,并且允许写的时候照常发生读操作。

②将锁打散

对小区来说,可以打散到楼号,单元号,楼层,门牌号,房间甚至床位。ConcurrentHashMap类似,默认分了16个segment,每个segment的内部进行加锁,和读写锁很类似。一个写请求到来,首先会分解到不同的Segment上面去,锁的粒度理论上降低了16倍。

③拆分synchronized

synchronized关键字在static方法上,相当于锁住了对象的class对象,这时任何一个静态方法增加synchronized修饰后,相互之间都是互斥的。如果想把不同的方法之间的互斥拆分开,此时可以定义多个对象,然后让不同的方法锁住不同的对象。

同样,synchronized修饰普通方法的话,相当于锁住this,作用范围是整个方法体。如果这是一个大方法,可以考虑范围是否可以缩小一些——拆分子方法,对某个必要的子方法加锁,或者通过锁块来隔离部分代码段。

单例模式举例


①普通方式

private static final Object obj = new Object();private static A a;

private static A getInstance() {synchronized(obj){

if(a == null) {a = new A();}};

return a;}

这样的缺点是,每次获取实例的时候都要加锁,这个开销是巨大的,如果存在征用,就是悲观锁

②可以采用“双重锁检查”:

private static final Object obj = new Object();private volatile static A a;

private static A getInstance() { if(a == null) {

synchronized(obj){

if(a == null) {a = new A();}};}

return a;}

这样只有第一次创建a的时候才会加锁,之后a已经创建之后就不需要加锁了,这也是线程池在判断corePoolSize和poolSize时候多次采用的。这时注意声明一下volatile,确保a的赋值可见。因为最外层的判断是没有加锁的,如果不保证可见性,对象的创建会有分配空间,初始化,赋值给外部引用等几个动作,单线程中赋值给外部引用和初始化属性的重新排序不会影响外部使用,但是在多线程中,另一个线程可能看到一个属性没有初始化好的对象,会使用volatile来保证可见性。如果这个对象是复杂对象,就无法保证内部属性的初始化全部完成。

③当然最好的方式还是定义时就初始化

private static final A a = new A();

private static A getInstance() {return a;}

这样的对象初始化是在类的<clinit>方法中完成的,会确保初始化过程中的线程安全。其他好的方式比如,借助Spring的初始化注入,在自定义的类中做一些static匿名块

无锁化



环形队列,它在逻辑上收尾相接,理论上可以通过数组或链表实现。

①数组实现

如果是数组,写入元素就像一个生产者,移动下标时,可以使用一个不断叠加的值与队列的长度求模,得到真正的数组下标来达到环形的目的。消费者也一样,有一个单独的叠加值。如果只有一个生产者和一个消费者,理论上不需要加锁,如果多个生产者多个消费者操作同一个下标,则需要通过CAS自旋方式来达到目的。这样锁的粒度就很小了,前面的许多队列就用了这种思想。

②链表实现

需要保留一个head和tail引用,生产者不断制造对象并修改tail,消费者不断从head取出对象。多个生产者通过CAS尝试将tail指向当前节点,成功的话将原先的tail节点的next指针指向自己。同样,多个消费者尝试将head修改为head.next,成功后原本的head节点就可以被取出了。

这种思想也有问题,加入生产者过快,或者生产者超过消费者到一周,这时需要接入很多重试机制,多次重试后太快的一方不得不处于等待状态来节约CPU,如果等待了就没实现无锁化。

生产者和消费者之间很难平衡,我们可以尽量加大数组和链表的长度,可以一定程度缓解这种问题,但是它又不能无限增加,因为内存会爆掉。如果有限,两者速度的差距始终会有碰撞的一天。

我们可以让生产者空闲一段时间,甚至可以帮消费者做一些事情。

我们也可以增加慢的一方的线程数来平衡彼此之间的速度。

死锁



所谓死锁,都是因为互掐导致的。死锁是偶然发生的,不是每次都会重现的。

比如两个人喝咖啡,一个人有水杯,另一个人有咖啡,如果谁都不给对方的话,那么谁都喝不成咖啡。

线程A中的代码:synchronized(obj1){ synchronized(obj2) {...} }

线程B中的代码:synchronized(obj2){ synchronized(obj1) {...} }

如果线程A将obj1锁住,线程B同时将obj2锁住,此时线程进一步需要锁obj2,线程B进一步要锁obj1,他们就互相锁住了。

实际场景中,情况会复杂很多:

①多个线程乱序征用某些对象锁,当多个线程之间的锁征用依赖形成环形结构时,可能会引起死锁。上面的例子是两个线程对两个资源的交叉征用。

②容易忽略对this加锁,也是对一个对象加锁,在该过程中去调用其他拥有锁的方法,可能导致不易发现的死锁。

饥饿死锁


会用fixed线程池实现fork/join会出现饥饿死锁的情况。

在任务分解的过程中,任务会越来越多,任务非常多的时候需要将子任务放到BlockingQueue中等待线程去执行,固定大小的线程池是有线程数量上限的,如果金进入阻塞队列,说明线程已经不够用了,“父任务”目前需要“子任务”返回结果,它自己的线程资源无法释放,“子任务”又再等待线程调度,这也是互掐——一个任务(子任务)得不到调度,其他任务(父任务)一直占着线程不释放,导致了饥饿死锁。

虽然Cached类型的线程池可以解决,但是会创建大量的线程去处理这个问题。

粒度过细引起的问题


private boolean isLocked = true;
public void lock() throws InterruptedException {
synchronized (this) {
while (isLocked) {//如果发现锁住,那么等待
this.wait();}}
synchronized (this) {
isLocked = true;}}
public void unlock() {
synchronized (this) {
if (isLocked) {
this.notify();
isLocked = false;}}}

第一个while,如果发现锁住,说明其他线程获取了锁,那么当前线程等待。如果发现没锁住,说明当前线程获取了锁,向下执行,把isLocked标记为锁住的状态。这里拆分了粒度,会出问题,第一个synchronized块执行之后,isLocked还没有设置为true,假设这个时候有另一个线程进入lock方法,发现isLocked是false,会继续向下执行,相当于没锁住,正常应该在unlock执行之后,isLocked才会为false,才会向下执行,这样就会导致一致性存在的问题,Slipped Conditions。

锁是为了解决:多个线程抢夺资源——同一个对象(不要忽略this修饰的)。

并发编程中使用非线程安全的数据结构


如ArrayList、LinkedList、HashMap、HashSet和StringBuilder等等。首先,如果没有写操作,那么这些都是线程安全的,只有读写同时发生时才会出现不安全的情况

以HashMap为例,会导致数据错乱,丢失数据,极端情况下会导致死循环。

①HashMap的数据结构

实际存储过程中,HashMap是一个数组,每个数组元素存储了一个单向链表的头,后面挂着一个单向链表。链表的每个元素都是一个Entry(包含了key,value,Next引用,Hash值),无论是get还是put操作都会先根据传入对象的hashCode找到数组的下标,然后根据该数组元素对应的单向链表找到指定的Entry——即Entry的key和传入对象的key是一致的。若到了链表的尾部都未找到,就返回null。

②rehash

如果插入节点的时候,Hash表的节点总数大于threshold,就会resize这个数组,即数组大小会发生扩容,通常扩容为原来的2倍。扩容过程中会导致同样的元素计算出的数组下标发生变化,所以需要遍历所有的Hash表的所有节点,即遍历每一个单向链表,每个节点按照新的hash表来结算自己的位置,逐个迁移到新的hash表上来,该过程叫做rehash

③两个线程插入同样的key

两个线程同时写入相同key,因为在判断的时候,这两个线程都发现这个key不存在,而key相同,识别出的下标也相同,说明在同一个链表当中,这时如果value不同,那么就违背了hash表中要求数据不允许重复的规定,叫做数据错乱。

④两个线程同时插入两个不同的key,但是key的hash相同

两个线程同时写入不同的key,但是key的hash相同,所以写入相同的数组元素对应的链表中。链表表头写入操作会将新的元素的next指向原先的第一个链表位置,最后再将头指针指向新的元素,那么这两个同时插入新元素肯定会丢一个。

⑤两个线程同时写入的时候发生rehash

线程A写入数据,发现需要rehash,在rehash某个数组元素对应的链表的时候,另一个线程进行了对这个链表的插入操作,但是A做rehash时不知道这个元素,所以这个元素就丢了。

如果B线程写入数据后,A的rehash还没完成,由于Table引用还未修改,看到的依然是老的数组,发现长度不够,也要做rehash,即基于老数组做rehash。这个过程中线程A做完rehash后将Table引用修改为A创建的新数组,B还在做rehash,所以不知道A创建的新Hash表是否发生了写入操作。在B做rehash的时间里,新的写入时对A创建的数组进行的(一个HashMap只有一个table,此时table引用指向了A创建的数组,一会儿就指向B创建的数组了),B在rehash后依然会将Table引用修改到B创建的数组上,所以期间所有的写入数据都会丢失!

即使数据不丢失,如果多个线程同时做rehash,在JVM中会创建非常多的垃圾内存。

⑥死循环

resize的transfer方法会导致同一个链表中的不同entry相互指向,这样在查找的时候就会发生死孙桓。

并发的效率问题


并发效率不一定高,可能存在大量的征用问题、锁问题。

①当只有一个CPU时,程序基本都是计算类的操作,多线程是无法提升效率的,带来的更多是线程的切换问题。但是如果程序有大量的BIO操作——IO密集型,是可以在这样的系统中使用多线程的,因为在IO等待的过程中,CPU是空闲的,其他的线程依然可以使用CPU来处理问题

之前举过例子,对于IO密集型,一段关键程序——被频繁访问的程序,耗时120ms,其中100ms做IO,20ms做运算,那么对于单CPU可以设置6个线程,因为IO时,CPU是可以被其他线程访问的,即可以用来做计算。

②当程序是十分简单的运算时,比如在一个大叔组中求最大值,平均值,多线程效果并不明显,甚至不如单线程,因为创建线程本身就需要花时间,而这个时间相比计算来说要大的多。

③当多线程从一个公共硬盘IO中读取大量数据时,希望以此来提升IO读取的性能,基本上很难(除非是SSD)。因为硬盘IO本身就是硬件总最慢的设备,并行的效果只会导致更多的征用。

在IO层面,我们可以根据某些隔离条件使用多线程去处理,但隔离的粒度一定是基于一些场景的测试数据,比如从远程数据库读取数据,到底开多少个连接并行拖数据不会影响在线业务,而且拖数据的性能是最佳的。

④在IO后如果需要做其他操作,是否要用另一个线程来读取IO数据,需要衡量。比如要考虑剩下的操作需要多长时间。如果100ms中用90ms或更多的时间做IO,剩余时间做其他事情,那么另一个线程等待了很久终于获取到锁了,该线程还没获取完数据,前一个做完事情的线程又回来请求数据,征用会无休止的进行下去。其实就是为了10ms,如果设置了5个、10个线程,征用会更加恐怖。

如果发现做完IO后,处理数据的时间占用比较大,这个时间浪费了不值得,可以使用生产者消费者模式来平衡,让一个线程去读,读完后放入一个队列中(放的操作相对IO可忽略不计)。那么这个线程一直在做读IO操作,这样在低级的磁盘上很多时候可以实现顺序读写操作,有效利用了磁盘宽带。





  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值