目前CPU的运算速度已经达到百亿次1秒,甚至更高的量级,家用电脑维持操作系统正常运行的进程也会有数十个,线程更是数以百计。所以,在现实场景中,为了提高生产率和高效地完成任务,处处均采用多线程和并发的运作方式。
首先从并发( Concurrency)与并行(Parallelism)说起。并发是指在某个时间段内,多任务交替处理的能力。所谓不患寡而惠不均,每个CPU不可能只顾着执行某个进程,让其他进程一直处于等待状态。所以,CPU把可执行时间均匀地分成若干份每个进程执行一段时间后,记录当前的工作状态,释放相关的执行资源并进入等待状态,让其他进程抢占 CPU 资源。并行是指同时处理多任务的能力。目前,CPU已经发展为多核,可以同时执行多个互不依赖的指令及执行块。并发与并行两个概念非常容易混淆,它们的核心区别在于进程是否同时执行。以KTV 唱歌为例,并行指的是有多少人可以使用话筒同时唱歌;并发指的是同一个话筒被多个人轮流使用。
并发与并行的目标都是尽可能快地执行完所有任务。以医生坐诊为例,某个科室有两个专家同时出诊,这就是两个并行任务,其中一个医生,时而问诊,时而查看化验单,然后继续问诊,突然又中断去处理病人的咨询,这就是并发。在并发环境下由于程序的封闭性被打破,出现了以下特点,
(1)并发程序之间有相互制约的关系。直接制约体现为一个程序需要另一个程序的计算结果;间接制约体现为多个程序竞争共享资源,如处理器、缓冲区等
(2)并发程序的执行过程是断断续续的。程序需要记忆现场指令及执行点。
(3)当并发数设置合理并且 CPU 拥有足够的处理能力时,并发会提高程序的运行效率。
线程安全
线程是CPU调度和分派的基本单位,为了更充分地利用CPU 资源,一般都会使用多线程进行处理。多线程的作用是提高任务的平均执行速度,但是会导致程序可理解性变差,编程难度加大。例如,楼下有一车砖头需要工人搬到21楼,如果10个人一起搬,速度一定比1个人搬要快,完成任务的总时间会极大减少。但是论单次的时间成本,由于楼梯交会等因素 10个人比1个人要慢。如果无限地增加人数,比如10000人参与搬砖时,反而会因为楼道拥堵不堪变得更慢,所以合适的人数才会使工作效率最大化。同理,合适的线程数才能让 CPU 资源被充分利用。
线程可以拥有自己的操作栈、程序计数器、局部变量表等资源,它与同一进程内的其他线程共享该进程的所有资源。线程在生命周期丙存在多种状态。如图所示,有NEW(新建状态)、RUNNABLE(就绪状)、RUNNING( 运行状态)BLOCKED(阻塞状态)、DEAD( 终止状态)五种状态。
(1)NEW,即新建状态,是线程被创建且未启动的状态。创建线程的方式有三种第一种是继承自Thread类,第二种是实现 Runnable接口,第三种是实现Callable接口。相比第一种,推荐第二种方式,因为继承自 Thread 类往往不符合里氏代换原则,而实现 Runnable 接口可以使编程更加灵活,对外暴露的细节比较少,让使用者专注于实现线程的run()方法上。第三种 Callable 接口的call()声明如下
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
由此可知,Callable 与 Runnable 有两点不同: 第一可以通过 call()获得返回值。前两种方式都有一个共同的缺陷,即在任务执行完成后,无法直接获取执行结果,借助共享变量等获取,而 Callable 和 Future 则很好地解决了这个问题,第二,call()可以抛出异常。而 Runnable 只有通过 setDefaultUncaughtExceptionHandler() 的方式才能在主线程中捕捉到子线程异常。
(2)RUNNABLE,即就绪状态,是调用 start() 之后运行之前的状态。线程的start()不能被多次调用,否则会抛出IllegalStateException 异常。
(3)RUNNING,即运行状态,是 run() 正在执行时线程的状态。线程可能会由于某些因素而退出 RUNNING,如时间、异常、锁、调度等。
(4)BLOCKED,即阻塞状态,进入此状态,有以下种情况。
- 同步阻塞:锁被其他线程占用。
- 主动阻塞:调用Thread 的某些方法,主动让出CPU执行权,比如sleep() join()等。
- 等待阻塞:执行了 wait0。
(5)DEAD,即终止状态,是 run()执行结束,或因异常退出后的状态,此状态不可逆转。
再用医生坐诊的例子说明,医生并发地处理多个病人的询问、开化验单、查看化验结果、开药等工作,任何一个环节一旦出现数据混淆,都可能引发严重的医疗事故。延伸到计算机的线程处理过程中,因为各个线程轮流占用CPU的计算资源,可能会出现某个线程尚未执行完就不得不中断的情况,容易导致线程不安全。例如,在服务端某个高并发业务共享某用户数据,首先A线程执行用户数据的查询任务,但数据尚未返回就退出CPU时间片:然后B线程抢占了CPU资源执行并覆盖了该用户数据最后A线程返回到执行现场,直接将B线程处理过后的用户数据返回给前端,导致页面显示数据错误。为保证线程安全,在多个线程并发地竞争共享资源时,通常采用同步机制协调各个线程的执行,以确保得到正确的结果。
线程安全问题只在多线程环境下才出现,单线程串行执行不存在此问题。保证高并发场景下的线程安全,可以从以下四个维度考量:
(1)数据单线程内可见。单线程总是安全的。通过限制数据仅在单线程内可见可以避免数据被其他线程篡改。最典型的就是线程局部变量,它存储在独立虚拟机栈帧的局部变量表中,与其他线程毫无瓜葛。ThreadLocal就是采用这种方式来实现线程安全的。
(2)只读对象。只读对象总是安全的。它的特性是允许复制、拒绝写入。最典型的只读对象有String、Integer等。一个对象想要拒绝任何写入,必须要满足以下条件使用final关键字修饰类,避免被继承,使用 private final关键字避免属性被中途修改;没有任何更新方法;返回值不能为可变对象。
(3)线程安全类。某些线程安全类的内部有非常明确的线程安全机制。比如StringBuffer就是一个线程安全类,它采用synchronized关键字来修饰相关方法。
(4)同步与锁机制。如果想要对某个对象进行并发更新操作,但又不属于上述三类,需要开发工程师在代码中实现安全的同步机制。虽然这个机制支持的并发场景很有价值,但非常复杂且容易出现问题。
线程安全的核心理念就是“要么只读,要么加锁”。合理利用好JDK提供的并发包往往能化腐朽为神奇。Java并发包(iava.util.concurrent,JUC)中大多数类注释都写有@author Doug Lea。如果说Java 是一本史书,那么 Doug Lea 绝对是开疆拓的伟大人物。Doug Lea 在当大学老师时,专攻并发编程和并发数据结构设计,主导设计了JUC并发包,提高了Java 并发编程的易用性,大大推进了 Java 的商用进程。并发包主要分成以下几个类族:
(1)线程同步类。这些类使线程间的协调更加容易,支持了更加丰富的线程协调场景,逐步淘汰了使用 Object的waitg 和notify0进行同步的方式。主要代表为CountDownLatch、Semaphore、CyclicBarrier 等。
(2)并发集合类。集合并发操作的要求是执行速度快,提取数据准。最著名的类非 ConcurrentHashMap 莫属,它不断地优化,由刚开始的锁分段到后来的CAS,不断地提升并发性能。其他还有 ConcurrentSkipListMap、CopyOnWriteArrayList、BlockingQueue 等。
(3)线程管理类。虽然 Thread 和 ThreadLocal在JDK1.0就已经引入,但是真正把 Thread 发扬光大的是线程池。根据实际场景的需要,提供了多种创建线程池的捷方式,如使用 Execuios 静态工厂或者使用ThreadPoolExecutor等。
(4)锁相关类。锁以 Lock 接口为核心,派生出在一些实际场景中进行互斥提作的锁相关类。最有名的是 ReentrantLock。锁的很多概念在弱化,是因为锁的实现在各种场景中已经通过类库封装进去了。
并发包中的类族有很多,差异比较微妙,开发工程师需要有很好的Java基础、逻辑思维能力,还需要有一定的数据结构基础,才能够彻底分清各个类族的优点、缺点及差异点。