阅读 1713

一个资深程序员应该学会用kotlin写一点线程安全的代码

前言

提到线程安全,很多人肯定知道synchronized,但事实上,它只是让线程保持安全的方法之一,Android是以java为核心编程的,而java并发编程中,难免会遇到线程安全的问题,至于为什么要处理线程安全的问题,由于Android应用进程中,默认都会有一个Main线程,网络数据一般是新创建的线程处理,那么就会存在多线程开发的场景,也就有了处理线程安全的必要性,现在大多数人都已经转为kotlin开发,那么对于kotlin,我们应该如何更好的实现线程安全呢?也就是我这次要给你们分享的核心

本次分享你将了解

  • 线程安全的级别
  • 什么场景下需要处理线程安全问题
  • 保持线程安全会带来哪些问题
  • 如何让一个类变得线程安全
  • kotlin中哪些习惯能让你更好的实现线程安全

线程安全的级别

你可能听说过,可以通过在方法的文档中查找synchronized修饰符来判断该方法是否线程安全的。这在几个方面来讲是错误的。在正常操作中,Javadoc的输出中没有包含synchronized修饰符,这是有原因的。方法声明中synchronized修饰符的存在是实现细节,而不是其API的一部分。它不能可靠地说明方法是是线程安全的。

此外,声称存在synchronized修饰符就足以文档记录线程安全性,这体现了线程安全性是要么全有要么全无属性的误解。实际上,线程安全有几个级别。要启用安全的并发使用,类必须清楚地文档记录它支持的线程安全级别。下面的列表总结了线程安全级别。它并非详尽无遗,但涵盖以下常见情况:

  • 不可变的(Immutable) —— 该类的实例看起来是不变的(constant)。不需要外部同步。示例包括String、Long和BigInteger(条目 17)。

  • 无条件线程安全(Unconditionally thread-safe) —— 此类的实例是可变的,但该类具有足够的内部同步,以便可以并发使用其实例而无需任何外部同步。 示例包括AtomicLong和ConcurrentHashMap。

  • 有条件线程安全(Conditionally thread-safe) —— 与无条件线程安全一样,但某些方法需要外部同步以便安全并发使用。 示例包括Collections.synchronized包装器返回的集合,其迭代器需要外部同步。

  • 非线程安全(Not thread-safe) —— 这个类的实例是可变的。 要并发使用它们,客户端必须使用其选择的外部同步来包围每个方法调用(或调用序列)。 示例包括通用集合实现,例如ArrayList和HashMap。

  • 线程对立(Thread-hostile) —— 即使每个方法调用都被外部同步包围,该类对于并发使用也是不安全的。线程对立通常是由于在不同步的情况下修改静态数据而导致的。没有人故意编写线程对立类;此类通常是由于没有考虑并发性而导致的。当发现类或方法与线程不相容时,通常将其修正或弃用。条目 78中的generateSerialNumber方法在没有内部同步的情况下是线程对立的,如第322页所述。

这些分类(除了线程对立)大致对应于《Java Concurrency in Practice》一书中的线程安全注解,分别是Immutable,ThreadSafe和NotThreadSafe [Goetz06,附录A]。 上述分类中的无条件和条件线程安全类别都包含在ThreadSafe注解中。

文档摘自(Effective Java 第三版)

什么场景下需要处理线程安全问题

我们反过来问?什么场景会存在线程不安全?

答: 线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。这是我们遇到的最常见的线程安全场景之一,也是最容易理解和忽略的。

保持线程安全会带来哪些问题

保证线程安全就难免会造成性能问题,性能问题有许多的表现形式,比如服务器的响应慢、吞吐量低、内存占用过多就属于性能问题。为什么会带来性能问题呢?主要从下面几个方面考虑:

上下文切换

在实际开发中,线程数往往是大于 CPU 核心数的,比如 CPU 核心数可能是 8 核、16 核,等等,但线程数可能达到成百上千个。这种情况下,操作系统就会按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行。但上下文切换带来的开销是比较大的,假设我们的任务内容非常短,比如只进行简单的计算,那么就有可能发生我们上下文切换带来的性能开销比执行线程本身内容带来的开销还要大的情况。

缓存失效

不仅上下文切换会带来性能问题,缓存失效也有可能带来性能问题。由于程序有很大概率会再次访问刚才访问过的数据,所以为了加速整个程序的运行,会使用缓存,这样我们在使用相同数据时就可以很快地获取数据。可一旦进行了线程调度,切换到其他线程,CPU就会去执行不同的代码,原有的缓存就很可能失效了,需要重新缓存新的数据,这也会造成一定的开销,所以线程调度器为了避免频繁地发生上下文切换,通常会给被调度到的线程设置最小的执行时间,也就是只有执行完这段时间之后,才可能进行下一次的调度,由此减少上下文切换的次数。

那么什么情况会导致密集的上下文切换呢?如果程序频繁地竞争锁,或者由于 IO 读写等原因导致频繁阻塞,那么这个程序就可能需要更多的上下文切换,这也就导致了更大的开销,我们应该尽量避免这种情况的发生。

协作开销

除了线程调度之外,线程协作同样也有可能带来性能问题。因为线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译器和 CPU 对其进行重排序等优化,也可能出于同步的目的,反复把线程工作内存的数据 flush 到主存中,然后再从主内存 refresh 到其他线程的工作内存中,等等。这些问题在单线程中并不存在,但在多线程中为了确保数据的正确性,就不得不采取上述方法,因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能。

如何让一个类变得线程安全

一条黄金法则就是:让类的对象不可变,那我们如何做到呢?或者说尽可能做到。因为完全不可变也是不可能的。那么具体怎么做呢?先看个例子

class Chenmo {
    private long count = 0;
    public void write() {
        System.out.println("我寻了半生的春天,你一笑便是了。");
        count++;
    }
}
复制代码

Chenmo 类在单线程环境下是可以准确统计出行数的,但多线程的环境下就不行了。因为递增运算 count++ 可以拆分为三个操作:读取 count,将 count 加 1,将计算结果赋值给 count。多线程的时候,这三个操作发生的时序可能是混乱的,最终统计出来的 count 值就会比预期的值小。

假定线程 A 正在修改 count 变量,这时候就要防止线程 B 或者线程 C 使用这个变量,从而保证线程 B 或者线程 C 在使用 count 的时候是线程 A 修改过后的状态。

怎么防止呢?可以在 write() 方法上加一个 synchronized 关键字。代码示例如下。

class Chenmo {
    private long count = 0;
    public synchronized void write() {
        System.out.println("我寻了半生的春天,你一笑便是了。");
        count++;
    }
}
复制代码

关键字 synchronized 是一种最简单的同步机制,可以确保同一时刻只有一个线程可以执行 write(),也就保证了 count++ 在多线程环境下是安全的。

在编写并发应用程序时,我们必须要保持一种正确的观念,那就是——首先要确保代码能够正确运行,然后再是如何提高代码的性能。

但众所周知,synchronized 的代价是昂贵的,多个线程之间访问 write() 方法是互斥的,线程 B 访问的时候必须要等待线程 A 访问结束,这无法体现出多线程的核心价值。

java.util.concurrent.atomic.AtomicInteger 是一个提供原子操作的 Integer 类,它提供的加减操作是线程安全的。于是我们可以这样修改 Chenmo 类,代码示例如下。

class Chenmo {
    private AtomicInteger count = new AtomicInteger(0);
    public void write() {
        System.out.println("我寻了半生的春天,你一笑便是了。");
        count.incrementAndGet();
    }
}
复制代码

write() 方法不再需要 synchronized 关键字保持同步,于是多线程之间就不再需要以互斥的方式来调用该方法,可以在一定程度上提升统计的效率。

文档摘自(Java 并发编程(二):线程安全性)内容详细请看沉默王二大佬的具体分析 cloud.tencent.com/developer/a… 文章内容后面有惊喜噢,一定要看(我未摘全部内容)

小结:经过上面的例子我们不难发现,它用到了几个级别的线程安全

  • AtomicInteger 无条件线程安全(Unconditionally thread-safe) 这个级别他是在内部同步的,但如果你用到了两个AtomicInteger,如果想保证安全,那就需要用到synchronized同步代码块了。也就到了第三种级别有条件线程安全(Conditionally thread-safe)
  • synchronized 有条件线程安全(Conditionally thread-safe)

考虑到性能问题,我们还是尽量不去用无条件线程安全、甚至有条件线程安全之类的,那么在kotlin中我们该如何保证线程安全呢?

kotlin中哪些习惯能让你更好的实现线程安全

尽可能使用val

在Kotlin中声明变量或类成员时,我们可以在两个关键字val和var之间选择。var基本上表示可以更改引用或变量的值,而val则表示它将是常量(仅初始化一次)如果我们要创建一个不可变的对象,则应使用val关键字声明其所有成员。 var关键字应仅用于局部变量(即函数或方法内部的变量)

使用Kotlin的不可变集合

使用val关键字只能确保变量始终引用同一对象。如:

val list = ArrayList<String>()
复制代码

“list”将始终引用相同的列表,但这并不意味着该列表将是恒定的:我们仍然可以在列表中添加或删除元素。这意味着,如果此列表是该类的成员,则代码有可能不是线程安全的:如果两个线程尝试调用更改列表内容的方法,则将进行并发修改且会出现例外。

为避免这种情况,请优先使用Kotlin的不可变集合。

val list = listOf<String>()
复制代码

但是这种情况我们必须知道列表中要包含哪些元素。否则就不适用。所以在开发过程中,如果能确定列表元素,那就优先使用它。就是这个逻辑。注意:在Kotlin中,List是不可变列表的一种,而ArrayList是可变列表。

使用数据类(data class)

数据类是一种特定类型的类,其主要目的是保存数据。这些数据类可以具有成员和方法,就像其他任何类一样,但是它们在编写线程安全代码时也提供了一个非常有用的方法:copy()方法。

假设我们声明了一个数据类来保存一些数据。在编写线程安全代码时,我们希望此类中的对象是不可变的,因此必须使用关键字val声明所有属性。现在,我们正在操纵此类中的一个对象,并且我们要“更改”其成员之一的值:我们别无选择,用该成员的新值来创建同一类的新对象。

当然,也可以封装此行为,以使其看起来像我们实际上在修改对象,而实际上在创建新对象:在这里,我们提出了一个线程安全的changePassword方法,该方法实际上并不会更改用户的密码,而是通过创建一个新User(它是原始密码的精确副本)并使用新密码来使它看起来像这样。

注意:copy()并未对对象进行深层复制,因此,如果数据对象存储对可变对象的引用,则对原始数据对象及其副本都将显示对该对象内容的任何更改。

使用同步块

并非总是只能使用不可变的对象。有时,如果我们必须频繁地编辑对象的内容,则为更改属性的值而创建不可变对象的新副本可能会非常昂贵。

如果是这种情况,我们将不得不使用同步块切换回更经典的方法。

但是正如我上面所说,如果可能,我们应该避免使用同步块,因为这可能导致死锁问题。仅当使用不可变对象会严重影响性能时,此解决方案才有用。

使用已经定义的线程安全结构

如果您无法采用之前给出的建议,那么您仍然可以做一些事情。 JVM定义了几个基本上是线程安全的类和集合。

例如AtomicInteger、还有线程安全的集合,例如Collections.synchronizedList。

总而言之,我们已经看到Kotlin提供的很多特性来确保线程安全:

  • val关键字可帮助我们定义不可变的对象
  • 提供不可变集合的标准库
  • 数据类提供的一些语法糖,帮助我们操纵不可变的对象

外链:

  • Java线程安全总结

www.jianshu.com/p/aa06b3ade…

  • 沉默王二大佬的线程安全性总结

cloud.tencent.com/developer/a…

好了本次就到此为止。