虽然可以通过多线程设计实现对CPU的多个核心的充分利用,从而提高应用的并发处理能力,但是由于多个线程共享进程的内存等硬件资源,所以如果多个线程同时操作一个数据,则可能出现数据不一致性问题。
为了解决多线程并发访问的数据不一致性问题,我们一般会使用互斥锁将多个需要对共享数据进行操作的线程进行同步。不过如果使用不当,也有可能会引发死锁问题。所以相对于单线程序设计,多线程设计对应用开发人员技术水平的要求更高,编程复杂度也更高。
除此之外,虽然多线程的并发和并行处理可以加快应用的处理速度,但是线程数量并不是越多越好。当线程数量远远多于CPU的核心数时,会导致频繁进行线程上下文切换,线程上下文切换与进程的上下文切换类似,也需要消耗CPU资源来对被暂停的线程的状态进行保存。另外,由于每个线程也需要占用一定的内存资源来维护自身状态,所以如果线程数量太多也会造成内存资源消耗过大。
由之前的分析可知,每个线程都会有自身独立的寄存器和方法调用栈来存放数据,而与其他线程共享的数据,则一般都是存放在主内存的。对于存放在线程内部的寄存器和方法调用栈中的私有数据而言,由于其他线程无法访问,因而不会存在数据不一致性问题。因为不管是读操作还是会改变数据状态的写操作,都是在该线程内部按顺序完成的,数据不会被其他线程修改。
对于存放在主内存中与同一个进程的其他线程共享的数据,因为这些数据可以被多个线程访问,所以如果多个线程同时对同一个数据进行写操作,则可能会出现不同线程之间的修改操作相互覆盖,导致出现数据不一致性问题。
为了保证进程内共享数据的一致性,避免多个线程同时进行写操作,一般会使用锁来实现任何时候只能存在一个线程对共享数据进行访问。不过使用锁会导致其他线程需要进行上下文切换和阻塞等待,影响应用的并发性能。
锁一般分为互斥锁和共享锁,或者称为写锁和读锁,其中互斥锁主要用于对写操作进行同步,即当某个线程在写数据时,其他线程不能同时对该数据进行读写操作;而共享锁是指多个线程可以同时读共享数据,所以其并发性能高于互斥锁。除使用锁之外,另一种方法是常通过硬件的原子指令来实现共享数据一致性,具体体现到软件层面就是CAS机制,即Compare And Swap,先比较后替换。
CAS机制的工作原理为在一个原子操作中先完成对旧数据的检查,如果符合预期,即没有被其他线程修改过,则进行更新操作;否则更新失败或者对于数字的自增操作则可以继续进行自旋操作来重试。CAS机制由于不需要加锁,或者说是一种乐观锁的实现方式,并发性能方面好于互斥锁。在后面章节会介绍的Java并发包的实现中,解决多线程的线程安全问题,也是大量使用了CAS机制来替代锁。
图2.8是以Java的内存模型JMM来更加直观地展示共享数据在多线程中的处理过程。
首先,被多个线程共享的数据存放在主内存,如图2.8所示的共享变量A和共享变量B。不过为了提高性能,Java线程一般会将共享数据拷贝一份到自身的本地内存,如图2.8的共享变量A在线程1和线程2都有一份数据副本,其中本地内存一般是指线程的寄存器。
图2.8 Java内存模型JMM
其次,各个线程可以在本地内存对该共享数据进行读写操作,这样就避免了每次都需要去主内存读写共享数据,缩短了数据访问时间。不过由于每个线程都是在操作自身本地内存的共享数据副本,不同线程之间的数据相互不可见。
当出现写操作时,每个线程都是基于线程自身数据副本来进行操作,如图2.8中的线程1和线程2,都是基于自身本地内存的共享变量A的独立副本来进行操作,然后将修改过的数据副本同步回主内存。此时如果线程1和线程2同时进行了不同的写操作,然后回写共享数据到主内存,就会出现数据相互覆盖的情况,导致数据不一致。
所以为了保证多线程环境中数据的一致性,需要使用锁或者其他手段来实现线程之间对共享数据的互斥操作和解决共享数据的可见性问题,对于Java关于线程可见性方面的解决方法在后续章节再详细分析。
为了保证多线程环境下共享数据的一致性问题,我们通常使用锁来同步多个线程对共享数据的访问,保证在任何情况下只能存在一个线程对共享数据进行写操作。但是在使用锁的时候需要注意避免死锁问题。当出现死锁时,持有锁的线程无法继续往下执行,也不会释放已经持有的锁,而其他在等待该锁的线程则会一直阻塞等待,最终导致整个系统出现卡顿,无法继续提供服务。
在多线程环境中,死锁问题通常出现在两个或多个操作需要持有多个不同的锁才能完成,而这些操作之间对于锁的请求顺序刚好相反的场景,如图2.9所示。
图2.9 线程死锁问题
操作1由步骤A和步骤B组成,其中完成步骤A需要获得锁1,完成步骤B需要获得锁2,而操作2由步骤C和步骤D组成,其中完成步骤C需要获得锁2,完整步骤D需要获得锁1,即刚好跟操作1相反。
如果此时存在线程1和线程2分别执行了以上两个操作,且执行操作1的线程1和执行操作2的线程2同时分别执行完步骤A和步骤C,需要分别继续执行步骤B和步骤D。此时线程1需要获取锁2,而此时锁2是被线程2持有的,所以线程1无法获得锁需要阻塞等待;线程2需要获得锁1,而此时锁1是被线程1持有的,所以线程2也不能继续往下执行,最终导致线程1和线程2都无限阻塞,出现死锁问题。
在编程时需要注意锁的请求顺序,避免出现多个锁的获取顺序相反导致出现死锁的问题。除此之外,对于锁的请求需要设置超时时间,当等待超时时,线程自动退出并抛出异常通知应用,如果线程持有锁,则自动释放该锁,从而避免整个系统卡死无法提供服务。
由于使用互斥锁进行多线程同步时,只能存在一个线程可以对共享数据进行写操作,其他没有获得锁的线程则需要进入阻塞等待状态,并在之后某个时刻继续竞争获取锁。此时对于进入阻塞等待状态的线程而言,CPU需要对这些线程的数据状态进行保存并在之后成功获取锁时进行恢复。
除此之外,由于操作系统是基于CPU时间片实现的抢占式任务调度,因而当某个线程的时间片用完时,该线程需要换出CPU给其他线程执行,此时需要CPU对该线程的相关数据状态进行保存,以便在之后重新轮到该线程执行时,该线程能够从之前的数据状态继续往下执行。
针对以上介绍的这些情况,CPU都需要对线程的数据状态进行保存和恢复,这个过程就是线程的上下文切换。如果线程数量较少,则CPU用于执行线程上下文切换的时间开销可以忽略不计,因为需要维护的线程数量少,CPU用于保存和恢复这些线程的数据状态的时间也少;并且由于线程数量少,进行线程上下文切换的次数也相应地减少。
但正如前文所述,线程的数量并不是越多越好的,当线程数量过多时,不但可能无法提高应用程序的并发处理能力,反而可能由于需要频繁进行线程上下文切换影响应用程序的并发性能,甚至低于单线程的处理性能。
应用程序创建过多的线程,除了会影响CPU的使用率之外,还会造成内存资源的大量消耗。虽然线程是轻量级的进程实现,不需要跟进程一样占用较多的硬件资源,但并不是说线程是完全不占用硬件资源的。每个线程也是需要使用寄存器和方法调用栈等内存资源来存放自身运行过程中的私有数据,故当线程数量过多时,也会导致系统的物理内存占用过多。