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