倚楼听风雨
淡看江湖路

阿里巴巴Java开发规约第一章-并发处理篇 吐血浅析

在上一篇《阿里巴巴Java开发规约第一章-控制语句篇》中老四就推荐过《Java并发编程实战》这本书,这里再次推荐一下。由于老四的并发编程技术底子也是十分的烂,浅析的内容当中如有不对的地方恳请各界大佬批评指导。

1.[强制] 获取单例对象需要保证线程安全,其中的方法也要保证线程安全。

说明: 资源驱动类、工具类、单例工厂类都需要注意。

老四附言:

单例是一种设计模式,可以参考一下老四写的《浅析设计模式第二十一章之单例模式 值得收藏》。

2.[强制] 创建线程或线程池时请指定有意义的线程名称,方便出错时回溯。

正例:

老四附言:

这个稍微有点经验的应该都会去遵守的。另外关于这条的规约的讲述,并发编程网-ifeve.com的加多大佬已经通过分析源码的方式为大家详尽解释了为什么要这么做,老四整理一下原文,优化整理了一下语言和代码排版,放到了自己的博客上面《创建线程或线程池时请指定有意义的线程名称,方便出错时回溯》。同时,由于没有联系上加多大大,所以该篇文章属于未授权转载,如果涉及侵权,请及时联系老四删除。除了这篇文章之外,老四在这里简单的复习一下线程与线程池等的基本概念。

进程简介:
进程的基础概念:

  • 进程是程序的一次执行
  • 进程是一个程序及其数据处理在处理机上顺序执行时所发生的活动.
  • 进程是具有独立功能的程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位.

进程的三种基本状态:

  • 就绪状态
  • 执行状态
  • 阻塞状态

进程的三本基本状态及其转换:

进程与线程的区别:

  • 进程是一个具有独立功能的程序关于某个数据集合的执行活动,不同的进程拥有独立的内存空间;
  • 线程是程序执行的最小单位,一个或多个线程组成一个进程,同一个进程中的所有线程共享相同的内存空间,运行时都有一个线程栈来保存变量值信息。

实现线程的两种方式:

  • 继承Thread类
  • 实现Runnable接口

两个构造方法:

完成线程真正功能的放在类的run()方法中,用Thread类的start方法启动线程,实质上Thread类就是实现了Runnable接口,其中的run()方法正是对Runnable接口中的run()方法的具体实现,实现Runnable接口的程序会创建一个Thread对象,并将Runnable对象与Thread对象相关联.
线程的生命周期:

  • 出生状态
  • 就绪状态
  • 运行状态
  • 等待状态
  • 休眠状态
  • 阻塞状态
  • 死亡状态

几个线程方法:

  • 调用wait()方法 --等待状态
  • notify()方法唤醒
  • nitifyAll()方法唤醒所有所有处于等待状态的线程
  • 调用sleep()方法 --休眠状态
  • 输入/输出请求 --阻塞状态
  • 等待输入/输出结束时 --就绪状态
  • run()执行完毕 --死亡状态

线程池:
谈这个概念我们需要先来了解一下Java中的Executor框架,它是基于生产者-消费者模式来设计,提供一种标准的方法将任务的提交过程与执行过程解耦,从而为灵活且强大的异步任务执行框架提供基础,其中,提交任务的操作就相当于生产者,执行任务的线程就相当于消费者。Executor接口如下所示:

再说回线程池,从字面上来理解,其实就是一个池子放了N个线程供调用。其实线程池是与工作队列密切相关的,是指管理一组同构工作线程的资源池。工作者线程从工作队列中获取任务,执行任务,然后返回线程池等待下一个任务。

线程池的优势:

  • 重用现有线程而不是每次都创建新的线程,在处理大量的并发请求的时候分摊线程创建与销毁过程中产生的巨大开销。
  • 提高响应性。当请求到达,池中的线程已经蓄势待发,避免了创建线程带来的开销
  • 合适的线程池容量可以有效的防止多线程相互竞争资源而使内存溢出

创建线程池的几种方式以及区别:

  • newFixedThreadPool: 将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生厂未预期的 Exception 而结束,那么线程池会补充一个新的线程).
  • newCachedThreadPool: 将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。
  • newSingleThreadExecutor: 是一个单线程的Executor,它创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代.newSingleThreadExecutor能确保依照任务在队列中的顺序来串行执行(例如FIFO.(First Input First Output,先进先出队列)LIFO.(Last In First Out,后进先出队列)优先级)。
  • : 创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似于Timer。

newFixedThreadPool和newCachedThreadPool这两个工厂方法返回通用的ThreadPool-Executor实例,这些实例可以直接用来构造专门用途的executor。几个创建线程池的示例如下:

3.[强制] 线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。

说明: 使用线程池的好处是减少在创建和销毁线程上所花的时间以及系统资源的开销,解决资源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者"过度切换"的问题。

老四附言:

这个在上一条线程池的优点中也提到了,这里简单的说一下关于线程池大小设置相关的一些知识供参考。线程池的的理想大小取决于被提交的任务类型以及系统部署的环境,既要避免过大,也要避免过小,如果线程池过大,大量的线程既毛事没有,还要在相对很少的CPU和内存上面竞争系统资源,耗内存的同时可能将系统资源耗尽。反之,如果过小,系统资源是相对不用考虑了,但是吞吐率(在计算机或数据通信系统,指的是单位时间内通过某通信信道或某个节点成功交付数据的平均速率,通常以每秒比特数为单位)就下来了,并占有这空闲的处理器使其无法工作。这里简单的列举一下影响线程池大小设置的一些相关因素:

  • CPU数量
  • 内存的大小
  • 任务是计算型还是I/O密集型
  • JDBC
  • 等等

-- 相对于计算密集型的任务,线程池的大小一般设置为Ncpu+1额外的线程保证CPU的时钟周期不会被浪费)

-- 对于I/O密集型任务,线程池应该更大一些,前辈总结的经验如下公式所示:

Nthreads = Ncpu * Ucpu * (1 + W/C)

其中,Ucpu取值在0到1之间,代表cpu的使用率,W/C代表计算时间等待的比率。

4.[强制] 线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

说明:  Executors返回的线程池对象的弊端如下:

  • FixedThreadPool和SingleThreadPool允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM(Out of Memory,内存溢出)。
  • CachedThreadPool和ScheduledThreadPool允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM

老四附言:

在第二条的解析中,其实老四的代码就是一种不好的演示,这里给出P3C关于如何更优化的创建线程池的方法:

5.[强制] SimpleDateFormat是线程不安全的类,一般不要定义为static变量,如果定义为static,必须加锁,或者使用DateUtils工具类。

正例: 注意线程安全,使用 DateUtils 。亦推荐如下处理:

说明: 如果是JDK8的应用,可以使用 Instant代替Date,LocalDateTime代替Calendar,DateTimeFormatter代替SimpleDateFormat,官方给出的解释: simple beautiful strong immutable thread - safe,大意就是简单漂亮的强不可变线程。

老四附言:

老四曾在《阿里巴巴Java开发规约第一章-其它篇》中的第五条也浅析过一些关于JDK8中的一些心得时间类,可以前去参考一下或者阅读一下官方文档(自备谷歌翻译)。

6.[强制] 高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。

说明: 尽可能使加锁的代码块工作量尽可能的小,避免在锁代码块中调用RPC(Remote Procedure Call,远程过程调用)方法。

老四附言:

越小的粒度的锁或者说原声无所数据结构给系统带来的损耗是越来越小的,相反不负责任的胡乱加锁不仅会使系统性能受影响,也是一种不负责任的提现。

7.[强制] 对多个资源、数据库表、对象同时加锁时,需要保持一致的加锁顺序,否则可能会造成死锁。

说明: 线程一需要对表A、B、C依次全部加锁后才可以进行更新操作,那么线程二的加锁顺序也必须是A、B、C,否则可能出现死锁。

老四附言:

那么问题来了,什么是死锁?老四想到这个对话:

翻译过来如下:

面试官: 解释一下什么叫做死锁,解释明白我们就会雇佣你。

我: 请先发offer,签完offer我就会向您解释(死锁)。

当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁时,那么它们将永远被阻塞。在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远地等待下去。这种情况就是最简单的死锁形式,也称为抱死。

JVM在解决死锁问题方面并不像数据库服务那样强大,可以选择一个牺牲者,放弃某个事务,从而使其他事务继续进行。当Java中发生死锁时,系统基本也就真的死了,基本不重启是不行的,当然,死锁基本都不是一开始就会显式的显示出来,基本都是突然来了巨大流量,即高负载的情况下容易出现死锁。

8.[强制] 并发修改同一记录时,避免更新丢失,需要加锁。要么在应用层加锁,要么在缓存加锁,要么在数据库层使用乐观锁,使用version 作为更新依据。

说明: 如果每次访问冲突概率小于20%,推荐使用乐观锁,否则使用悲观锁。乐观锁的重试次数不得小于3次。

老四附言:

这里浅析一下乐观锁与悲观锁,网上已经一大堆了,这里仅当做复习与存档就好。

悲观锁:
在关系数据库管理系统里,悲观并发控制(又名"悲观锁",缩写"PCC")是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作都对某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。
操作流程: 在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。

MySQL InnoDB支持悲观锁(默认行级锁,基于索引,要不然锁表): 要使用悲观锁,我们必须关闭mysql数据库的自动提交属性(

set autocommit=0),因为MySQL默认使用autocommit模式,也就是说,当你执行一个更新操作后,MySQL会立刻将结果进行提交。
示例如下:

悲观锁的优缺点:

  • "先取锁再访问"的保守策略

  • 为数据处理的安全提供了保证

  • 在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会;

  • 在只读型事务处理中由于不会产生冲突,也没必要使用锁,这样做只能增加系统负载;

  • 会降低了并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理行数;

乐观锁:

在关系数据库管理系统里,乐观并发控制(又名"乐观锁",缩写"OCC")是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。
操作流程: 数据版本,为数据增加的一个版本标识。当读取数据时,将版本标识的值一同读出,数据每更新一次,同时对版本标识进行更新。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对,如果数据库表当前版本号与第一次取出来的版本标识值相等,则予以更新,否则认为是过期数据。
示例如下:

乐观锁的优缺点:

  • 乐观并发控制相信事务之间的数据竞争(data race)的概率较小。

  • 不会产生任何锁和死锁。

  • 两个事务都读取了数据库的某一行,经过修改以后写回数据库会有问题。

9.[强制] 多线程并行处理定时任务时,Timer运行多个TimeTask时,只要其中之一没有捕获抛出的异常,其它任务便会自动终止运行,使用 ScheduledExecutorService则没有这个问题。

老四附言:

Timer类负责管理延迟任务以及周期任务,但是这个,类的设计存在缺陷,所以jdk5之后我们一般使用ScheduledThreadPoolExecutor来代替而不建议使用Timer类。简单说一下Timer类的两个缺陷:

  • Timer在执行所有任务定时任务的时候只会创建一个线程。如果某个任务的的执行时间过长,name将破坏其他时间任务的定时精确性。
  • 如果TimerTask抛出来一个未检查的异常,Timer线程并不捕获异常,而是终止定时线程。这样就导致了线程的执行无法恢复,Timer也被取消,新的任务也不会被调度。

错误的Timer行为范例如下:

运行结果如下:

你可能会认为程序执行6秒后退出,但是因为Timer运行多个TimeTask时,只要其中之一没有捕获抛出的异常,其它任务便会自动终止运行,所以控制台抛出了一个Timer already cancelled.的异常。我们改用ScheduledThreadPoolExecutor来代替这种方案,规约官方的示例如下:

10.[推荐] 使用CountDownLatch进行异步转同步操作,每个线程退出前必须调用countDown方法,线程执行代码注意catch异常,确保countDown方法被执行到,避免主线程无法执行至await方法,直到超时才返回结果。

说明: 注意,子线程抛出异常堆栈,不能在主线程try-catch到。

老四附言:

CountDownLatch类是java.util.concurrent包中众多可阻塞类中的一种,可以用它实现类似计数器的功能,基于AQS(AbstractQueuedSynchronizer)构建的。由于老四对于并发编程的理解也不是很深入,不敢班门弄斧,这里就简单的介绍一下AQS:

AQS(AbstractQueuedSynchronizer,队列同步器),是基于模板方法创建的用来构建锁或者其他同步组件的基础框架。它使用一个int成员变量来表示同步状态,通过CAS(compare and swap,比较并交换,原子操作的一种,多线程中实现不被打断的数据交换操作)作对同步状态进行修改,确保状态的改变是安全的。通过内置的 FIFO(First In First Out,先进先出)队列来完成资源获取线程的排队工作。最基本的操作包括各种形式的获取操作和释放操作。

关于设计模式中的模板方法模式可以参考老四的这篇《浅析设计模式第十章-模板方法模式》文章。

基于AQS构建的可阻塞类列表如下(均在java.util.concurrent包中):

  • ReentrantLock
  • Semaphore
  • ReentrantReadWriteLock
  • CountDownLatch
  • SynchronousQueue
  • FutureTask

更多的相关知识可以参考一下两篇文章:

接下来再简单的说一下这个CountDownLatch类,CountDownLatch在同步状态中保存的是当前的计数值。countDown方法调用release,从而导致计数值递减,并且当计数值为零时,解除所有等待线程的阻塞。await调用acquire,当计数器为零时,acquire将立即返回,否则将阻塞。

CountDownLatch类最重要的部分源码:

CountDownLatch的基本示例:

执行结果:

等待2个子线程执行完毕...
子线程THREAD_TWO正在执行
子线程THREAD_ONE正在执行
子线程THREAD_ONE执行完毕
子线程THREAD_TWO执行完毕

11.[推荐] 避免Random实例被多线程使用,虽然共享该实例是线程安全的,但会因竞争同一seed(种子数)导致的性能下降。

说明: Random实例包括java.util.Random的实例或者Math.random() 的方式。
正例: 在JDK7之后,可以直接使用API中的ThreadLocalRandom,而在JDK7之前,需要编码保证每个线程持有一个实例。

老四附言:

我们都知道Random的特点是相同种子数的Random对象,对应相同次数生成的随机数字是完全相同的。也就是说,即使是产生随机数,也是按照某种既定的算法来产生的,这样的设定在多线程的条件下就会因为竞争seed导致系统环境性能受损。

12.[推荐] 在并发场景下,通过双重检查锁(double - checked locking)实现延迟初始化的优化问题隐患(可参考The "Double - Checked Locking is Broken" Declaration),推荐解决方案中较为简单一种(适用于JDK5及以上版本),将目标属性声明volatile型 。

反例:

老四附言:

老四曾在这篇文章《浅析设计模式第二十一章之单例模式 值得收藏》中介绍过一些关于双重检查的内容,另外关于volatile关键字您可以参考老四的这篇《Java关键字volatile浅析 线程安全的利器》。另外这是《The "Double - Checked Locking is Broken" Declaration》的在线阅读地址,可能需要科学上网,看不懂英文请自备谷歌翻译。

13.[参考] volatile解决多线程内存不可见问题。对于一写多读,是可以解决变量同步问题,但是如果多写,同样无法解决线程安全问题。如果是count++操作,使用如下类实现: AtomicInteger count = new AtomicInteger(); count.addAndGet(1);如果是JDK8,推荐使用LongAdder对象,比AtomicLong性能更好(减少乐观锁的重试次数)。

老四附言:

关于volatile的基本解析可以参考老四的《Java关键字volatile浅析 线程安全的利器》,在这篇文章,老四提到过,因为volatile修饰的变量在并发运算下不是线程安全的,volatile保证不了原子性。另外该篇文章也尽可能详述了关于i++的线程不安全的解决办法,可以适当参考。

14.[参考] HashMap在容量不够进行resize时由于高并发可能出现死链,导致 CPU 飙升,在开发过程中可以使用其它数据结构或加锁来规避此风险。

老四附言:

我们都知道,键值对entry的厨师默认值长度是16,其中map里面定义了一个负载因子,默认值是0.75,HashMap能容纳的最大键值对就是长度乘负载因子,然后当HashMap中的键值对个数超过这个最大值的时候就会触发resize()方法来进行扩容工作。扩容的基本原理其实就是将原来的table重新计算hash值再放入到新的table中。HashMap的resize部分源码如下(jdk8):

基础知识里面我们都知道,HashMap是线程不安全的,多线程环境下要使用ConcurrentHashMap。那么HasnMap在多线程的环境下是如何引起的呢?老四就不在班门弄斧,早在很久以前就有大佬强调这些问题了,具体可以参考一下这篇《疫苗: JAVA HASHMAP的死循环》文章,另外,jdk8之后HashMap的resize()方法改为由之前的数组加链表的数据结构变成数组加链表/红黑树的结构,但也是线程不安全的。有机会老四也会写一篇关于HashMap源码的浅析。自己闲着没事也可以读一读这些涉及到各种数据结构的源码。

15.[参考] ThreadLocal无法解决共享对象的更新问题, ThreadLocal对象建议使用static修饰。这个变量是针对一个线程内所有操作共享的,所以设置为静态变量,所有此类实例共享此静态变量,也就是说在类第一次被使用时装载,只分配一块存储空间,所有此类的对象(只要是这个线程内定义的)都可以操控这个变量。

老四附言:

当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭(Thread Confinement)。维持线程封闭性的一种更规范的方法就是使用ThreadLocal,这个类能使线程中的某个值与保存值得对象关联起来,提供了get和set等访问接口和方法,这些接口和方法为每个使用该变量的线程保存一份独立的副本,所以在get的时候总是会返回由当前执行线程在调用set时设置的最新值。所以ThreadLocal通常用于单例或者全局变量进行共享。基本的示例如下:

假设你需要将一个单线程应用程序移植到多线程环境当中,通过将共享的全局变量转换为ThreadLocal对象可以维持线程安全性。然而,如果将应用程序范围内的缓存转换为线程局部的缓存,就不会有太大作用。另外就是孤尽所言ThreadLocal对象的更新问题,所以加上static进行修饰是正确的做法。

更博不易,如果觉得文章对你有帮助并且有能力的老铁烦请赞助盒烟钱,点我去赞助。或者扫描文章下面的微信/支付宝二维码打赏任意金额,老四这里抱拳了。赞助时请备注姓名或者昵称,因为您的署名会出现在赞赏列表页面,您的赞赏钱财也会被用于小站的服务器运维上面,再次抱拳。

赞(16) 给你买杜蕾斯
本站原创文章受自媒体平台原创保护,未经允许不得转载高老四博客 » 阿里巴巴Java开发规约第一章-并发处理篇
分享到: 更多 (0)

开始你的表演 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下老四,鼓励我更好的创作

支付宝扫一扫打赏

微信扫一扫打赏