Java 并发编程技术实践之路专栏导读

引言

CSDN 2019 年的博客之星评选活动提高了参赛要求,具有付费专栏的博主才能参加。而笔者年中曾在一个付费知识星球里,公布了自己下半年的目标,之一就是参加 2019 年博客之星评选活动。本来以为很简单的事情,官方画风突转,还是让笔者始料未及!

笔者一直坚持写博客,分享自己从业过程中的一些开发经验,今年累计发布了 80 篇原创技术文,9 篇译文。总感觉付费专栏门槛高、约束多,所以一直没尝试。

借着这个契机,正好完成参选目标,顺便锻炼一下自己的写作水平,于是就申请了一个新专栏,名为 “Java 并发编程技术实践之路”,作为参与博客之星的敲门砖,同时也给自己 2019 年增加一件值得称道的小事!

这就是本专栏的产生背景了,铺垫的稍微有点多,各位读者朋友见谅!笔者将重新整理自己所掌握的 Java 并发编程技术知识,系统并完善地介绍个人阅读 Java 并发包源码的心得,再结合工作中碰到的坑点,为大家带来一个内容更丰富、更系统的 Java 并发专栏。

此篇为专栏的开题导读,笔者将以一张 Java 并发知识图谱为主线,带领大家温习一下或熟悉或陌生的 Java 并发技术。

并发知识图谱

这是一张还算完整的 Java 线程知识图谱,细化的每一块,包含的知识点也不少!但是,仔细看看第 1- 9 部分的基础知识,作为 Java 开发人员,多少也涉猎到一些,即使没用过,也可能在框架中见过。
在这里插入图片描述
【此图来源于网络,如有侵权,请联系本人删除】

接下来,笔者将围绕该知识图谱中的技术点,带领大家摸底自测一下它们的概念和作用,专栏后续章节会深入介绍相关的知识点,力求涵盖第 1-9 章节中的绝大部分技术,为读者呈现一套完整的 Java 并发编程实践技术知识库。

先从 Java 面试和笔试必考的题目入手,如何创建一个线程呢?这就是第 2-3 部分的内容了。

线程,并发编程的第一步

线程是许多编程语言不可或缺的重要功能,被称为轻量级进程。大学计算机与信息计算专业的基础课程,《操作系统原理》一书中, “线程” 和 “进程” 常被放在一起比较:进程是操作系统的调用单位,共享操作系统资源,而线程是进程的调度单位,线程共享进程的资源。大多数现代操作系统,都是以线程为基本的调度单位,而不是进程。

操作系统位于计算机硬件与应用软件之间,本质也是一个软件。它的出现,使得计算机能够执行多个程序,《Java 并发编程实践》中,也介绍了并发简史,我们来了解一下:

之所以在计算机中加入操作系统来实现多个程序的同时执行,主要是基于以下原因:
资源利用率。在某些情况下,程序必须等待某个外部操作执行完成,而在等待时程序无法执行其他工作。因此,如果在等待的同时可以运行另一个程序,那么无疑将提高资源的利用率。
公平性。不同用户和程序对计算机上的资源有着同等的使用权。一种高效的运行方式是通过粗粒度的时间分配使这些用户和程序能够共享计算机资源,而不是由一个程序从头运行到尾,然后再启动下一个程序。
便利性。通常来说,在计算多个任务时,应该编写多个程序,每个程序执行一个任务并在必要时通信,这比只编写一个程序来计算所有任务更容易实现。

如何创建线程

Java 并发编程技能,几乎是 Java 中高级开发人员的标配,并发编程这个话题,也是历久弥新。线程又是并发的根基,它的创建方式也是 Java 面试或笔试中必备的题目,一起来回忆下线程的两种创建方式。

  • 继承 Thread 类,重写其 run 方法
  • 编写一个 Runnable 实现类作为线程任务

JDK8 以后,用 lambda 表达式可以很方便地创建一个基于 Runnable 的线程:

Runnable task = ()->{
	//TODO 任务处理逻辑
	System.out.println(Thread.currentThread().getName());
};

Thread t = new Thread(task);
t.start();

以上是创建线程的基本方法,相当简单。值得注意的是, 线程的 start 方法,它是以 native 方式实现的,还涉及到操作系统层面的内容。线程从创建、启动、执行、销毁,整个生命过程中的每一步都需要消耗系统资源和时间,因此,线程使用过程中需要注意两点:

  1. 保证线程使用完成后能被销毁:便于释放线程所占用的资源;
  2. 使用线程池,避免手动创建线程:缓存线程。

线程池的利用

线程的创建知识,笔者以为它仅仅起到学习语法和练习的作用,而实际开发中,很少使用自定义线程,基本上都是通过线程池来获取线程资源的。《阿里开发者规范》并发处理章节中,也规定了线程池的使用原则,并阐述了理由:

【强制】线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
说明:使用线程池的好处是减少在创建和销毁线程上所花的时间以及系统资源的开销,解决资源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者 “过度切换”的问题。

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

锁,线程安全的依托

编写并发程序时,必须正确地使用线程和锁,其核心在于对共享可变状态的访问操作进行管理。 JVM 中,对象状态的本质是存储在状态变量中的数据,而共享,意味着状态可能同时被多个线程访问。如果对象的操作管理不当,就可能使得程序遭遇安全性问题,出现不正确的运行结果。

并发编程中, “安全” 的含义是什么呢?

在线程安全性的定义中,最核心的概念是正确性。正确性的含义是,某个类的行为与其规范完全一致。这里所说的规范,是指程序设计过程中对类状态的约束条件,以及描述对象操作结果的后验条件,它们通常会被写入文档,指导程序功能的验证过程。

而什么样的类一定是安全的呢?在多线程环境下,线程安全的类不需要调用者做任何的额外操作,在该类上的所有操作都能得到规范中约定的正确结果。

如何保证类的安全性

对于状态可变的类,必须在类定义的时候就添加足够的同步控制,才能保证它的安全。设计线程安全的类,需要从原子性、可见性和有序性三个方面入手,借助各种同步工具,保证类的行为和状态的一致性。

常用的策略有:

第一,对于 i++ 这类由 “读取-修改-写入” 三个操作序列组成的复合操作,可以直接用 Java 的原子变量 AtomicXXX 来管理类的状态,它们具有天然的原子性保证。

第二,锁是 Java 保证原子性和可见性的内置机制,将对象的操作封装在由锁保护的代码块内。同时,建立对象状态的并发访问管理策略,明确不变性规范。

第三,除非是功能的需要,否则不要在线程之间共享变量,尽量用 final 语义将状态定义为不可变的。

第四,Java 提供了一种弱同步机制 volatile ,可以用它来保证状态的可见性和有序性。

锁的使用常识

锁是一种常用的同步方式,它可以实现独占访问、保证内存可见性。Java 提供了两种锁,synchronized 代表的内置锁和 Lock 及其子类为代表的显式锁。复合操作或者实现线程安全的类时,可以用锁来保证独占访问和可见性。

关于锁的一些常识,来看这段文字,它来自 Java 并发包的作者们联合编著的《Java 并发编程实践》一书:

一种常见的错误是认为,只有在写入共享变量时才需要使用同步,然而事实并非如此。对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。
每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

每个共享的和可变的变量都应该只由一个锁来保护,这个常识很重要,后续章节中笔者会以案例来讲解违背该常识而引发的错误结果。

并发包中的工具类

JDK 的 java.concurrent.util 包,简称 JUC ,它里面包含了大量的并发类,了解这些类的功能和使用场景还是很有必要的,这样才能到什么山上唱什么歌,碰到什么场景用什么并发类!

这里以两种线程协作类为例,简要介绍下它们的使用场景。

第一种协作类,CountDownLatch

JDK 源码中,对这个类的注释是这样说的:

A synchronization aid that allows one or more threads to wait until a
set of operations being performed in other threads completes.

顾名思义,减数锁。它就像一个减数器,N个同时工作的人,每个人谁完成了工作就按下它,计数器减一,当计数器减少到 0 时,说明所有人都完成了工作,那么总管就可以拿汇总结果来继续其他的事情了。

作为一个线程协同工具,它允许一个或多个线程等待其他线程完成一系列的操作后,再继续自己的其他操作。适合一等多的场景,比如,主类必须等待所有子线程完成后,才能继续后续操作。我们熟悉的 Tomcat 容器,它的 NioEndpoint 组件销毁方法中,也使用了 CountDownLatch 来通知工作线程结束轮询,以便能释放线程资源。

第二种协作类,CyclicBarrier

JDK 官方文档对 CyclicBarrier 的解释是这样的:

synchronization aid that allows a set of threads to all wait for each
other to reach a common barrier point. CyclicBarriers are useful in
programs involving a fixed sized party of threads that must
occasionally wait for each other.

Barrier ,是障碍物、栅栏之意,线程一旦调用它的 await 方法后就处于被阻拦状态,直到所有线程都执行过 await 后,线程的阻塞状态才解除。Cyclic ,是循环的意思,是指当所有线程都解除阻塞状态后,它还可以被重复使用。

该类适用于一组线程需要等待其他所有线程都到达某个状态的场景,即多等一。类似我们生活中集体出游时的状态,每个人上车后都需要等着,直到所有人都到了,司机才能开车出发,每个人的等待才能结束。

此外,JUC 中的线程池工具类 ExecutorsExecutorServiceThreadPoolExecutor ,它们有什么区别呢?线程安全集合明星 ConcurrentHashMap ,它为什么能够提供比普通 Map 更高的并发效率呢?这些都是需要掌握的知识点,稍后将继续为大家剖析其中的要义。

yield 和 sleep ,它们的区别你知道吗

yieldsleepThread 类的两个实例方法,它们都会让出 CPU 资源给其他线程,你知道它们的区别是什么吗?

先说说它们的相同之处吧,它们都会不会释放自己所拥有的对象锁,都会让出 CPU 资源;不同之处在于:

  • yield 会只会给优先级比自己高的线程提供 CPU 使用机会,如果没有,则还会被再次调度获得 CPU 资源的
  • sleep 没有优先级限制,可能使优先级低的线程得到 CPU 资源

这两个方法都能让操作系统资源能够重新分配,给其他线程以运行的机会。有一种巧妙的使用场景是长任务,即执行时间比较长的任务,在中途适当执行一下 sleep 方法,让 CPU 能够重新调度,使得其他任务有机会运行。

经典模型:生产者和消费者

最后,来说说线程通信的一个经典模型 “生产者和消费者”,引自百度百科的一段:

生产者和消费者,也称有限缓冲问题(英语:Bounded-buffer problem),是一个多线程同步问题的经典案例。
该问题描述了两个共享固定大小缓冲区的线程【“生产者”和“消费者”】,在实际运行时会发生的问题。生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据。

因为缓冲区共享,所以就涉及到两点重要的通信约束:

  1. 缓冲区满的时候,生产者不能再添加数据,应该阻塞等待,直到缓冲区有空位;
  2. 缓冲区空的时候,消费者不能再获取数据,应该阻塞等待,直到有新的数据加入缓冲区。

要保证这个通信约束,也很容易,使用锁和条件队列就好了:生产者和消费者有各自要等待的条件,一旦条件不满足,就阻塞在该条件队列上,直到另一个线程唤醒自己。Java 的锁分为内置锁和显式锁,所以生产者和消费者模型也有两种实现方式。

下一章节,笔者将继续讲述锁的知识,并基于内置锁和内置条件队列实现这一经典线程模型。
专栏写作路漫漫,终于迈出了第一步!

发布了234 篇原创文章 · 获赞 494 · 访问量 37万+

猜你喜欢

转载自blog.csdn.net/wojiushiwo945you/article/details/103610241
今日推荐