您的浏览器过于古老 & 陈旧。为了更好的访问体验, 请 升级你的浏览器
Ready 发布于2020年01月04日 22:14 最近更新于 2020年05月05日 18:38

原创 面向开发者的 Web 应用安全入门指南(8):线程并发安全

482 次浏览 读完需要≈ 30 分钟 Java

内容目录

线程并发安全问题是许多编程语言中一个绕不过去的坎,也是日常开发过程中很容易被忽视的一个问题。

提到线程并发,就不得不提多线程。当然,有些编程语言默认并不支持多线程,但如果要实现一些特殊业务需求,或者想要深入了解操作系统或编程语言的一些底层机制,那么多线程的相关知识也是必不可少的。

PHP默认不支持多线程,不过可以安装 pthread 扩展以支持多线程;JavaScript 一般也是单线程运行的,不过也可以使用 Web Worker 实现多线程运行环境。

本文不是线程/多线程知识的科普文章,因此本文将默认读者已经掌握了多线程相关的基础知识,以便于我们将焦点集中在线程并发安全问题上面。

什么情况下需要考虑线程并发安全问题?

我们知道,在 Java Web 应用的 Servlet 容器环境中,每当应用服务器接收到一个请求,就会分发给一个单独的线程去负责处理该请求。

所以,Java Web 应用天然就是一个典型的多线程应用。那么,什么情况下,我们需要考虑线程并发安全问题呢?

概括地说,当多个线程有可能「同时」读写(必须存在写操作的可能性)同一个数据时,这样的场景就可能导致线程并发问题。

对于导致线程并发问题的场景,我们可以总结出以下几个要素:

  • 该数据不是只读的,而是可能被写入(修改)的。如果数据是固定不可变的,那就不存在所谓的线程并发问题。
  • 读写」可以是多个线程「同时」写入,也可以是「同时」有读有写(有的线程正在读取,有的线程正在写入)。
  • 这里的「同时」并不是要求时间概念上的完全一致,而是说线程A尚未完成对数据的操作,此时线程B却已经开始了对同一数据的操作。

下面,我们先来看看线程并发问题的具体 Java 代码示例。

案例一

/**
 * 模拟 售票点 的 售票业务
 * 一共有100张票,序号依次为 1 ~ 100,由5个售票点并行出售
 */
public class TicketCounter {

	/** 剩余可售的票数 */
	static int ticket = 100;

	public static void sellTicket() {
		while (ticket > 0) { // 引用 ①
			try {
				Thread.sleep(10); // 延迟 10ms 以模拟执行额外业务处理逻辑所需的时间,也给后续线程留下充足的启动时间
			} catch (InterruptedException e) {
				// ignore exception
				e.printStackTrace();
				break; // 发生异常时中断循环
			}
			int remain = ticket--; // 引用 ②
			System.out.println("售票点 [" + Thread.currentThread().getName() + "] 正在售出,当前售票序号:" + remain);
		}
	}

	public static void main(String[] args) {
		final int threadCount = 5;
		final ExecutorService service = Executors.newFixedThreadPool(threadCount);
		for (int i = 0; i < threadCount; i++) {
			// 此处代码基于 Java 8+,如果是低版本,请使用 Runnable 接口实现替代
			service.execute(TicketCounter::sellTicket);
		}
		service.shutdown(); // 这里不是立即关闭线程池,而是不再接受新任务
	}
}

运行上述代码,我们可以得到类似如下结果输出(每次执行,结果都可能不一样):

售票点 [pool-1-thread-3] 正在售出,当前售票序号:98
售票点 [pool-1-thread-1] 正在售出,当前售票序号:96
售票点 [pool-1-thread-4] 正在售出,当前售票序号:97
售票点 [pool-1-thread-2] 正在售出,当前售票序号:99
售票点 [pool-1-thread-5] 正在售出,当前售票序号:100
售票点 [pool-1-thread-3] 正在售出,当前售票序号:95
售票点 [pool-1-thread-1] 正在售出,当前售票序号:91
售票点 [pool-1-thread-2] 正在售出,当前售票序号:92
售票点 [pool-1-thread-4] 正在售出,当前售票序号:93
售票点 [pool-1-thread-5] 正在售出,当前售票序号:94
售票点 [pool-1-thread-3] 正在售出,当前售票序号:90
……(省略中间部分)
售票点 [pool-1-thread-5] 正在售出,当前售票序号:7
售票点 [pool-1-thread-2] 正在售出,当前售票序号:6
售票点 [pool-1-thread-1] 正在售出,当前售票序号:8
售票点 [pool-1-thread-4] 正在售出,当前售票序号:5
售票点 [pool-1-thread-2] 正在售出,当前售票序号:4
售票点 [pool-1-thread-1] 正在售出,当前售票序号:3
售票点 [pool-1-thread-3] 正在售出,当前售票序号:2
售票点 [pool-1-thread-4] 正在售出,当前售票序号:1
售票点 [pool-1-thread-5] 正在售出,当前售票序号:0
售票点 [pool-1-thread-3] 正在售出,当前售票序号:-1
售票点 [pool-1-thread-2] 正在售出,当前售票序号:-3
售票点 [pool-1-thread-1] 正在售出,当前售票序号:-2

不难发现,上述程序代码出现了非常明显的线程并发问题——控制台输出的售票序号居然出现了预期之外0 和 负数

为什么会出现这样的问题呢?

因为,在sellTicket()方法中,完成一次完整的售票业务,整个过程有两处地方用到了共享的静态变量ticket:第一处是测试ticket是否 > 0 的判断操作;第二处是在每次售票成功时对ticket进行自减(减数)操作。

但是,在这一次售票的整个过程中,这两处操作并不是一个原子性操作(在业务上不可再拆分的最小操作单元)。

也就是说,这两个操作是两个分别独立的操作,分属于两行代码,一个线程先后执行这两行代码是存在时间差的,其他的线程就可能趁虚而入

例如:当ticket = 1时,线程A 已经通过了第一处ticket > 0的判断,尚未执行第二处的自减操作(因此ticket还是1)。此时,线程B 也恰好执行到第一处,由于此时ticket还是1,所以也通过了第一处ticket > 0的判断,那么 线程A 和 线程B 执行到第二处代码时,其中一个线程会先将ticket自减为 0,接着被另一个自减为 -1。很显然,变量ticket就因此出现了严重的业务异常问题。

严谨地说,类似于ticket++ticket--这样的一行代码,也不是一个原子性操作。也就是说,在sellTicket()方法中,哪怕只有这一行代码,在多线程的执行环境下,最终得到的也不一定都是正确的结果。

那么,该如何解决类似这样的线程并发安全问题呢?

找到了问题所在,解决起来自然就不难。既然它不是一个原子性操作,我们就想办法让其变成一个不可拆分的原子性操作。

这两行代码及其之间的代码肯定是无法被简化为一行进行原子性操作的代码的。CPU执行代码指令的时候,也只能一个个指令先后依次执行。只要CPU无法在一个原子性指令周期内执行完所有的代码,那就肯定存在时间差。

那么,我们就只能想办法:让其它线程没办法趁虚(时间差)而入

于是,我们就可以想到一个简单的办法,使用synchronized关键字来修饰代码块(对于具有相同监视对象synchronized代码块,多个线程只能互斥执行——这应该不用再详细科普了吧?):

public static void sellTicket() {
		while (true) {
			// synchronized 必须放 while 循环里面,否则会被一个线程(售票点)全部执行完(卖完所有票)
			// 其它线程(售票点)都将没有售票的机会
			synchronized (TicketCounter.class) {
				if (ticket > 0) {
					try {
						Thread.sleep(10);
					} catch (InterruptedException e) {
						// ignore exception
						e.printStackTrace();
						break;
					}
					int remain = ticket--;
					System.out.println("售票点 [" + Thread.currentThread().getName() + "] 正在售出,当前售票序号:" + remain);
				} else {
					break; 
					// 【注意】 break 必不可少,或者将 while(true) 改为 while(ticket > 0)
				}
			}
			break;
		}
	}

案例二

在日常的编程开发过程中,我们有时候需要自建一个简单的基于内存的键值对缓存,便于在不同的请求之间共享一些轻量级的缓存数据。

下面的示例代码就是一个典型的简单缓存工具类:

public abstract class CacheUtils {

	/** 用于缓存数据的 Map */
	static final Map<String, Object> cache = new HashMap<>();

	/** 添加 或 更新一个缓存对象 */
	public static void putCache(String key, Object value){
		cache.put(key, value);
	}

	/** 根据 key 获取对应的缓存对象 */
	public static Object getCache(String key){
		return cache.get(key);
	}

	/** 根据 key 删除对应的缓存对象 */
	public static Object removeCache(String key){
		return cache.remove(key);
	}

}

毫无疑问,在多线程环境中使用这样的缓存,极有可能导致线程并发异常,因为这里使用的java.util.HashMap本身就不是线程安全的

在多线程环境中使用非线程安全的集合或容器实现(例如ArrayListHashMapTreeMapHashSetStringBuilder等),是很多安全意识不足的开发人员非常容易犯的错误之一。

遇到这种情况,我们需要将其替换为线程安全的对应实现版本,例如:将java.util.HashMap替换为java.util.Hashtablejava.util.concurrent.ConcurrentHashMap(Java 5 新增,推荐使用后者);将java.util.ArrayList替换为java.util.Vector

对于那些 Java 类库中没有对应的线程安全实现版本的集合,我们也可以使用java.util.Collections中的synchronizedList(List)synchronizedSet(Set)synchronizedMap(Map)synchronizedCollection(Collection) 等方法,将其转换为线程安全的集合实现。

对于那些对并发性能要求较为苛刻的场景,可能需要我们借用第三方类库,或者根据实际需求并结合读写锁、CAS、锁拆分、引用替换等相关知识,自行实现对应的工具类。

案例三

多数时候,我们可能还需要一个日期和字符串相互转换的工具,此时,我们一般会用到java.text.SimpleDateFormat。不过,如果每次使用都构造一个新的基于yyyy-MM-dd模式的SimpleDateFormat,这也是比较浪费性能的。因此,为了提高性能,一些开发人员也会对常用模式的SimpleDateFormat实例进行缓存。

public class DateUtils {

	static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");

	/** 日期字符串 转 Date 对象 */
	public static Date parse(String source) throws ParseException {
		return DATE_FORMAT.parse(source);
	}

	/** 将 Date 对象格式化为字符串 */
	public static String format(Date date) {
		return DATE_FORMAT.format(date);
	}
}

不过,这样做也存在较为严重的线程并发安全隐患。因为,根据 SimpleDateFormat 的官方文档说明:

Date formats are not synchronized. It is recommended to create separate format instances for each thread. If multiple threads access a format concurrently, it must be synchronized externally.

由此可知,SimpleDateFormat非线程安全的。在多线程环境下,使用类似的工具类,极易导致线程并发异常。

官方文档推荐我们为每一个线程都维持一个单独的实例。此时,我们就需要用到ThreadLocal来解决线程安全问题:

public class DateUtils {

	static final ThreadLocal<SimpleDateFormat> DATE_FORMAT_THREAD_LOCAL = new ThreadLocal<SimpleDateFormat>() {
		@Override
		protected SimpleDateFormat initialValue() {
			return new SimpleDateFormat("yyyy-MM-dd");
		}
	};

	public static Date parse(String pattern) throws ParseException {
		return DATE_FORMAT_THREAD_LOCAL.get().parse(pattern);
	}

	public static String format(Date date) {
		return DATE_FORMAT_THREAD_LOCAL.get().format(date);
	}
}

想要高性能,就需要缓存并复用一些常用的对象。但是该对象的实现可能并不是线程安全的,因此我们就可以为每个线程都维持一个独立的实例。由于Servlet容器的多线程是循环复用的(这很关键,否则ThreadLocal将失去意义),因此ThreadLocal才有了用武之地。
此外,要实现日期格式相互转换的高并发性能,我们也可以使用第三方的线程安全版本的实现,例如org.apache.commons.lang3.time.FastDateFormat(该类从3.2版本开始全面兼容SimpleDateFormat,之前只是大部分兼容,但一般也不会影响正常使用)。

案例四

有些时候,我们需要在内存中缓存一些接口调用所需的配置数据对象,而且还需要支持通过后台操作对配置数据进行即时更新。

下面的示例代码就是一个典型的配置数据读/写工具类:

/** 存储配置数据的实体类 */
public class Config {

	private String username;
	private String password;
	private String token;

	public String getUsername() {
		return username;
	}

	public String getPassword() {
		return password;
	}

	public String getToken() {
		return token;
	}

	static Config config;

	/** 获取配置对象(用于外部使用) */
	public static Config getConfig() {
		// lazy load
		if (config == null) {
			config = new Config();
			// 实际的配置数据可能来源于配置文件或数据库
			config.username = "username"; // 代码 ①
			config.password = "password";
			config.token = "token";
		}
		return config;
	}

	/** 使用新的配置参数更新配置,并返回更新后的配置对象 */
	public static Config updateConfig(String newUsername, String newPassword, String newToken) {
		config.username = newUsername;
		config.password = newPassword;
		config.token = newToken;
		return config;
	}

}

但是,毫无疑问,上述代码也存在较为严重的线程安全问题。而且,不管是getConfig(),还是updateConfig()都存在问题:

  • getConfig():在初始化默认配置数据时,如果存在多个线程争用,其中部分线程可能得到的是尚未完全初始化的配置数据。例如:线程A 已经执行到了 代码①(见代码中的注释) 处,接着 线程B 执行代码,此时静态变量config已经不是null了,因此直接返回了只初始化了username属性的对象实例。
  • updateConfig():由于配置数据有多个属性,需要先后依次赋值——这不是一个原子性操作。有可能 线程A 刚刚更新了username属性,线程B 就紧接着调用了 getConfig() 将新的username属性(加上旧的passwordtoken属性)拿去使用了。

因此,我们也需要解决上述代码中的线程并发安全隐患。

解决方案一(错误)

最简单暴力的方式就是直接给两个静态方法加上synchronized关键字:

	/** 获取配置对象(用于外部使用) */
	public static synchronized Config getConfig() {
		// lazy load
		if (config == null) {
			config = new Config();
			config.username = "username";
			config.password = "password";
			config.token = "token";
		}
		return config;
	}

	/** 使用新的参数更新配置 */
	public static synchronized Config updateConfig(String newUsername, String newPassword, String newToken) {
		config.username = newUsername;
		config.password = newPassword;
		config.token = newToken;
		return config;
	}
解决方案二

解决方案一虽然「看似」简单暴力地解决了线程并发问题,但却严重了限制了其并发性能。其实,我们可以利用Java对象引用传递的机制来巧妙地实现无锁解决线程并发问题:

static Config config;

/** 获取配置对象(用于外部使用) */
public static Config getConfig() {
	Config cfg = config;
	// lazy load
	if (cfg == null) {
		cfg = new Config();
		cfg.username = "username";
		cfg.password = "password";
		cfg.token = "token";
		config = cfg;
	}
	return cfg;
}

/** 使用新的参数更新配置 */
public static Config updateConfig(String newUsername, String newPassword, String newToken) {
	Config cfg = new Config();
	cfg.username = newUsername;
	cfg.password = newPassword;
	cfg.token = newToken;
	config = cfg;
	return cfg;
}
/* 上述代码中 静态变量 config 在方法中只能用来 与 本地变量 cfg 进行引用交接,
 * 整个配置数据初始化的过程必须完全基于 cfg 进行属性赋值,初始化全部完成后,才能将引用交接回 config 。
 * 上述代码建立在 getConfig() 允许存在多个相同的默认配置数据的实例的前提下,
 * 如果要确保单例唯一,那么仍然需要通过双重判断的synchronized 代码块来解决,以下是代码示例:
 *
	public static Config getConfig() {
		// lazy load
		if (config == null) {
			// 只有首次初始化 config 时,若出现多线程争用,才会进入 synchronized 代码块
			// 后续调用无需再进入,自然也就没有线程锁相关的性能开销
			synchronized (Config.class) {
				if (config == null) { // 需要再次判断,否则会出现多个实例
					config = new Config();
					config.username = "username";
					config.password = "password";
					config.token = "token";
				}
			}
		}
		return config;
	}
 */

必读

在实际的项目开发过程中,线程并发安全问题是需要具体问题具体分析的。有些时候,看似使用了线程锁、看似使用了线程安全的工具类,也照样可能会出现线程并发问题。

比如,上面的解决方案一其实并不能解决线程并发问题(这里留一个坑,大家先自行思考为什么,想不明白的请在问下评论,届时我们再补坑。想明白了的同学也可以在评论中说出自己的正解)。

不过,只要我们时刻保持警惕,只要一个变量有可能会被多个线程读写,那么我们就不得不全面细致地分析其出现线程并发问题的可能性,只有这样,线程并发问题才会渐渐地远离我们。

  • CodePlayer技术交流群1
  • CodePlayer技术交流群2

2 条评论

二周 · 4个月前

卖票:可以使用AtomicInteger来避免同步或者加锁

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class TicketCounter {
	/** 剩余可售的票数 */
	static AtomicInteger ticket = new AtomicInteger(100);

	public static void sellTicket() {
		while (true) { // 引用 ①
			int old = ticket.get();
			if (old == 0) {
				break;
			}
			int update = old - 1;
			try {
				Thread.sleep(10); // 延迟 10ms 以模拟执行额外业务处理逻辑所需的时间,也给后续线程留下充足的启动时间
			} catch (InterruptedException e) {
				// ignore exception
				e.printStackTrace();
				break; // 发生异常时中断循环
			}
			if (ticket.compareAndSet(old, update)) {
				System.out.println("售票点 [" + Thread.currentThread().getName() + "] 正在售出,当前售票序号:" + old);
			}
		}
	}

	public static void main(String[] args) {
		final int threadCount = 5;
		final ExecutorService service = Executors.newFixedThreadPool(threadCount);
		for (int i = 0; i < threadCount; i++) {
			// 此处代码基于 Java 8+,如果是低版本,请使用 Runnable 接口实现替代
			service.execute(TicketCounter::sellTicket);
		}
		service.shutdown(); // 这里不是立即关闭线程池,而是不再接受新任务
	}
}

案例四的解决方案一存在的问题:

虽然synchronized关键字保证了updateConfiggetConfig这两个方法的并发安全性,但是由于config对象是应用传递,存在线程1拿到config对象以后线程2调用了updateConfig方法,比如线程2更新了username还未更新password线程1就会拿到新的username和旧的password。也就是说线程1拿到的数据可能不是完整的。

0 0 0
Ready [作者] · 24天前

之前看到网上有人说ThreadLocal并不是用来解决线程并发安全问题的。理由是 ThreadLocal 是在每个线程各自存储了一份数据副本,并不存在多个线程之间共享同一份数据的情况,因此不能说是用来解决线程并发安全问题。

不过,我个人并不赞成这个观点。我认为 ThreadLocal 是可以用来解决某些特殊场景下的线程并发安全问题的。

就以上面的SimpleDateFormat为例,由于它的内部实现并不是线程安全的。所以我们不能在多线程环境下使用它的同一个实例,否则会导致线程并发安全问题。但是,我们也不想在每次使用时都重新构造一个新的实例,因为这会带来相对较大的性能开销。

因此,我们要实现的目标就是:既不能每次使用都重新构造一个新的实例,也要保证不会出现线程并发安全问题。我们希望 更好的性能 和 线程并发安全 是可以兼得的。

在使用基于ThreadLocal的解决方案后,我们的目标就很快实现了,之前会出现的线程并发安全问题也没有了。

既然之前会出现的线程并发安全问题没有了,也就是被解决了。既然我们使用它解决了这个线程并发安全问题,为什么还不能这么说呢?

我个人认为:我们编程的首要目的就是解决现实世界的实际问题,不管用什么方式,只要能够解决这个问题,那就没什么问题。

我们也没必要非得局限于固定的思路——只有保留了多个线程之间相互争用同一数据这一符合“线程并发”定义的场景基础,才配叫做解决了这个线程并发安全问题。我们可以稍微思维发散一下:只要让产生这个问题的场景基础无法再出现,当然也算是解决了这个问题。

因此,要解决类似于SimpleDateFormat这种线程并发安全问题,我们可以想到3种方案:

  1. 自己重新实现一个线程安全的增强版本(也可以使用第三方的)。
  2. 多线程之间不再进行实例共享,每次使用都重新构造一个新的实例。
  3. 在每个线程各自维持一个唯一的实例副本,并在对应的线程单独使用。

当然,解决掉一个问题,也可能会带来新的问题(比如 性能问题)。因此,我们需要根据各自的实际情况(使用频率、工作量等)进行合理地权衡取舍,从而选择最适合当场业务场景的解决方案。

不过,我们也要搞清楚每个解决方案的不同原理,不能一概而论。

0 0 0

撰写评论

打开导航菜单