第六章 - 线程安全
线程安全
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的 --- Brian Goetz
多线程安全问题是什么?
问题: 多线程在修改共享资源的时候, 会出现主存和线程工作内存之间的交流是有延迟性的, 不能够即时的更新数据, 也不能够实时监控主存的数据是否被修改, 所以线程经常得到了旧的值, 或者线程已经算好了值, 但是没能即时更新到主存
解决方案:
①线程如果能够即时的读取到最新的主存值和线程能够即时的更新自己修改的值到主存, 那么解决了一个问题, 就是可见性问题;
②但是却无法解决线程A和线程B'同时'读取到主存的值, 然后进行操作, '同时'更新到主存的问题, 这个时候总有一个值的操作被替换了(这里的同时并不是真的同时, 只不过是线程上下文切换导致的线程读取到一个旧值), 产生了错误的值, 所以需要进行线程的串行化处理和原子性化的问题, 实际解决方案就是加锁;
③但是还存在问题, 就是指令重排序的问题, jvm在实际使用过程中存在指令优化问题, 所以还是需要使用内存屏障防止指令重排问题也就是有序性, 实际解决方案就是加上volatile关键字;
举个问题案例的例子:
Z A B 都有一根火柴共有三根, Z 不知道 A B 有火柴, A和B都知道Z有一根火柴, 但是 A B 不知道对方有火柴, Z 想知道到底有几根火柴
Z 手持一根火柴, 在自己的纸张上写了个数字 1
A 发现 Z 的纸张上写了 1 后, 抄了下来, 发现自己也有一根火柴, 然后把 1 + 1 并且算出结果 2 , 然后出去玩了下
B 发现 Z 的纸张上写了 1 后, 抄了下来, 发现自己也有一根火柴, 然后把 1 + 1 并且算出结果 2 , 然后出去玩了下
这个时候 A 告诉 Z 说, 火柴共有 2 根
B 也这样说
然后 Z 在自己的纸条上写了 2
至此, 错误出现了
然后使用上我们的解决方案的例子:
首先串行化线程执行
其次解决可见性问题和防止指令重排问题
之后
Z A B 都有一根火柴共有三根, Z 不知道 A B 有火柴, A和B都知道Z有一根火柴, 但是 A B 不知道对方有火柴, Z 想知道到底有几根火柴, AB需要排队
Z 手持一根火柴, 在自己的纸张上写了个数字 1
A 发现 Z 的纸张上写了 1 后, 抄了下来, 发现自己也有一根火柴, 然后把 1 + 1 并且算出结果 2, 马上告诉 Z 让他更新, 此时B还在排队中...
B 发现 Z 的纸张上写了 2 后, 抄了下来, 发现自己也有一根火柴, 然后把 2 + 1 并且算出结果 3 马上告诉 Z 让他更新
多线程问题需要满足哪些条件?
存在线程安全问题必须满足三个条件:
1.有共享变量
2.处在多线程环境下
3.共享变量有修改(读和写)操作或者每次修改结果都不一样(修改不是创建)。
只要不满足一点一个条件, 就不存在线程安全问题
自己总结的判断线程不安全方法
首先条件铁定要满足基本条件
多线程
共享资源
修改共享资源
然后这个时候就可以借助原子性操作不可分割的方法对我们的方法调用进行画方框, 每个方框代表一个原子性操作(必须由同一个线程执行完毕后释放才允许换个线程进行执行的代码块)
如果在临界区出现多个方框则表示线程不安全
例如:
假设add和remve都是线程安全的, 但是存在两个方框, 则线程不安全
即时上面的remove方法去掉了剩下一个add方法也是线程不安全的
即使没有错误但是情况结果
100 * 500 = 50000这么多次, 但是len才只有4w左右
还是使用的假设法
前提就是 modCount 不是常量资源共享, 共享资源有改变
elementData不是常量, 共享资源有改变
size也不是常量, 共享资源add方法内部有改变
即便modCount不管也不是线程安全的
多个线程假定同时读取 s 都加上 1 , 这里就会丢失一次加1的机会
还有一种确认线程安全的方法
既然时多线程的, 那么找到共享资源, 判断如果线程执行到了这里, 突然失去时间片, 切换到另一个线程, 此时共享资源是否被前一个线程即时更新了, 如果没有则线程不安全, 否则线程安全
还有一种方法是通过共享资源判断
判断函数的参数是否存在线程不安全的共享资源
不可变
不可变(Immutable)的对象一定是线程安全的, 使用 final 修饰的基础类型数据就是绝对安全的, 还有很多如 List.of() Map.of(), Set.of产生的集合都是线程安全的
绝对线程安全
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
上面这种线程安全是很难实现的, 所以java绝对线程安全是没有的
例如Vector, 说是线程安全的, 但是Vector存在add和remove还有get一起使用的情况下是线程不安全的
相对线程安全
对象单次的操作是线程安全的, 就是相对线程安全
java语言中存在很多这样的集合比如 Vector, Hashtable还有使用上Collections的synchronizedCollection方法的包装集合都是相对线程安全的
线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。我们平常说一个类不是线程安全的,通常就是指这种情况。Java类库API中大部分的类都是线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等
线程对立
线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。由于Java语言天生就支持多线程的特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
一个线程对立的例子是Thread类的suspend()和resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险——假如suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。也正是这个原因,suspend()和resume()方法都已经被声明废弃了。常见的线程对立的操作还有System.setIn()、Sytem.setOut()和System.runFinalizersOnExit()等。
线程安全的实现方法
互斥同步
互斥同步(Mutual Exclusion & Synchronization)是一种最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。因此在“互斥同步”这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的
在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(BlockStructured)的同步语法。synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。
在jdk1.5之前synchronized关键字效率是非常慢的, 所以引入了可重入锁ReentrantLock, 这种方法和synchronized 相似, 但是在那个时候Lock的方法效率比synchronized效率要高的高, 单核处理器下两种锁的吞吐量对比如下图
多核下比较:
并且ReentrantLock还提供了很多功能
等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助
公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量
锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可
但是它也是有问题的, lock需要记得在finally中调用unlock方法
在jdk1.6甚至到jdk8+后Lock和synchronized的效率是差不多的, 所以前面的此时在现在来看是无所谓的
前面说了这么多的区别, 现在考虑下到底用那种方法?
lock的方法比较灵活, 还提供了公平锁, 锁绑定多个条件, 等待可中断等方法, 所以在复杂的情况下推荐使用这种方式, 但是要记得在finally中调用unlock方法
使用synchronized的话, 代码比较简洁清晰, 合适在比较简单的环境下使用
非阻塞同步
互斥同步使线程阻塞和唤醒, 也叫阻塞同步, 是悲观锁, 这种方案需要控制线程阻塞和唤醒都需要用到中断也就是系统调用, 而且共享变量不论是否安全, 都会上锁, 这种方法其实还是有比较不好的地方, 随着硬件指令集的发展,我们已经有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁(Lock-Free)编程
为什么笔者说使用乐观并发策略需要“硬件指令集的发展”?因为我们必须要求操作和冲突检测这两个步骤具备原子性。靠什么来保证原子性?如果这里再使用互斥同步来保证就完全失去意义了,所以我们只能靠硬件来实现这件事情,硬件保证某些从语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成,这类指令常用的有:
测试并设置(Test-and-Set);
获取并增加(Fetch-and-Increment);
交换(Swap);
比较并交换(Compare-and-Swap,下文称CAS);
加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/SC;
其中,前面的三条是20世纪就已经存在于大多数指令集之中的处理器指令,后面的两条是现代处理器新增的,而且这两条指令的目的和功能也是类似的。在IA64、x86指令集中有用cmpxchg指令完成的CAS功能,在SPARC-TSO中也有用casa指令实现的,而在ARM和PowerPC架构下,则需要使用一对ldrex/strex指令来完成LL/SC的功能。因为Java里最终暴露出来的是CAS操作,所以我们以CAS指令为例进行讲解
CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断
上面可能不太懂, 我总结下
cas指令有三个操作数, 其一线程工作内存; 其二主内存; 其三新值
判断工作内存的变量是否等于主存? 是 更新, 否 补偿(一般补偿方法是循环, 获取工作主存的值作为工作内存再次判断直到成功位置)
它存在一个逻辑漏洞:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,那就能说明它的值没有被其他线程改变过了吗?这是不能的,因为如果在这段期间它的值曾经被改成B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA问题”, 虽然java提供了个类AtomicStampedReference解决这个问题, 不过这个ABA问题其实不影响程序的正确性, 所以AtomicStampedReference很鸡肋
变量的线程安全分析
成员变量和静态成员变量是否线程安全????
如果它没有共享, 则线程安全
如果它共享了, 又根据它是否存在修改分为两类
- 只有对它有读操作, 则线程安全
- 如果有读写操作, 则这段代码是临界区, 需要考虑线程安全问题
局部变量是否线程安全???
局部变量是线程安全的(局部变量是只有线程自己有的资源, 不共享就没有线程安全问题)
但是局部变量的引用对象则未必
- 如果该对象没有逃离方法的作用域是线程安全的
- 如果逃出了方法的作用域, 则线程不安全
日期转换的问题
下面的代码在运行时,由于 SimpleDateFormat 不是线程安全的
@Slf4j(topic = "c.DateFormatSafeDemo")
public class DateFormatSafeDemo {
public static void main(String[] args) {
// 线程不安全
unsafeFunc();
}
private static void dateTimeFormatterFunc() {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
TemporalAccessor parse = formatter.parse("2020-10-10");
log.debug("parse = {}", parse);
}).start();
}
}
private static void unsafeFunc() {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
log.debug("{}", dateFormat.parse("1999-02-02"));
} catch (ParseException e) {
log.debug("{}", e);
}
}).start();
}
}
}
具体分析是否线程安全
上面那个list是线程不安全的, 首先满足共享资源, 其次这个list存在 add 和 remove
上面的list是线程安全的, 多个线程调用method1方法, 但是这个方法的list在每次调用的时候都是新的list对象, 而且method2和3方法都是private的, 外部多线程只能调用method1方法, 所以它是线程安全的
这个局部变量的list就是线程不安全的, 即时它是局部变量, 但是它传递搞了其他public修饰的方法上, 如果这两个public的方法被多线程调用则, list也将不安全, 简单点就是局部变量的引用暴露给其他线程
常见的线程安全类
但是他们的组合却不是线程安全的, 虽然他们单个都是原子性的, 但是组合使用就是两个原子性操作是不安全的
比如这样就不是线程安全的
不可变类线程安全性
为什么String是线程安全的???
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
private final byte coder;
/** Cache the hash code for the string */
private int hash; // Default to 0
查看字段 coder是线程安全的, hash它会在对象头生成的时候生成出来, 所以也是线程安全的, 现在就value数组不敢保证线程安全性, 即使它被 volatile修饰(volatile只能修饰引用变量不能修饰引用变量所指向的内存空间)
final 的使用
发现该类、类中所有属性都是 final 的
属性用 final 修饰保证了该属性是只读的,不能修改
类用 final 修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性
保护性拷贝
但有同学会说,使用字符串时,也有一些跟修改相关的方法啊,比如 substring 等,那么下面就看一看这些方法是
如何实现的,就以 substring 为例:
class Zhazha {
// beginIndex 局部变量 --- 线程安全
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
// length线程安全, String是常量的, 所以他的长度也是固定的
int subLen = length() - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
if (beginIndex == 0) {
return this;
}
// 根据判断, 这个函数也是线程安全的, 不管false还是true, 都是new一个新的String对象
return isLatin1() ? StringLatin1.newString(value, beginIndex, subLen)
: StringUTF16.newString(value, beginIndex, subLen);
}
}
我们现在要关注的是是否修改了value数组
class Zhazha
{
public static String newString(byte[] val, int index, int len) {
return new String(Arrays.copyOfRange(val, index, index + len),
LATIN1);
}
}
发现其内部还是没有对value数组存在修改, 只有新建一个新的String对象, 把引用地址丢给string的引用变量, 前面的String对象直接丢弃
构造新字符串对象时,会生成新的 char[] value,对内容进行复制 。这种通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】
final底层原理
public class TestFinal {
final int a = 20;
}
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: bipush 20
7: putfield #2 // Field a:I
<-- 写屏障
10: return
发现 final 变量的赋值也会通过 putfield 指令来完成,同样在这条指令之后也会加入写屏障,保证在其它线程读到
它的值时不会出现为 0 的情况
无状态
在 web 阶段学习时,设计 Servlet 时为了保证其线程安全,都会有这样的建议,不要为 Servlet 设置成员变量,这
种没有任何成员变量的类是线程安全的
因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】
实例分析
前提: 如果doGet方法中存在对上述共享资源的改变
线程不安全; 线程安全; 线程安全; 线程不安全; 线程不安全
线程不安全, MyServlet 方法是单例模式, 所以 userService 对象会被多个线程共享(产生了共享资源)但如果不追究userService对象的源码话, 那么它暂时是线程安全的, 而对象中的 count 对象也是线程共享资源, 并且 count 还存在修改(这个修改本身就是线程不安全的), 所以它也是线程不安全的
线程不安全, MyAspect 是单例的, 所以存在多线程场景, start 是共享资源并且 start 对象存在修改而且每次结果都不一样
线程安全; 线程安全; 线程安全;
首先MyServlet是单例, 所以存在多线程, userService 是共享资源, 但是没有修改, 找 userDao 是共享资源, 但是没有修改, 发现 UserDaoImpl 是线程安全的, 里面全部都是局部变量
从上到下: 线程安全, 线程安全, 线程不安全, 所以前面的全部变成变成不安全
答案: 线程不安全; 线程不安全; 线程不安全
详解: 首先 MyServlet 是单例, 存在多线程环境, user Service是线程共享资源, 但是没有修改, 所以是安全的, userDao是线程共享资源, 不考虑底层的话, 是线程安全的, 但是考虑底层的话. 那个 conn 是线程不安全的, 首先 conn 是共享变量, conn 存在变化并且每次修改都不一样
不存在线程安全问题; 一个线程一个 userDao
上面这种用法就和我们的 String 类为啥要使用 final 的原因了
抽象方法的实现有可能是线程不安全的, 这个 foo 方法可能被多个线程运行, 而参数 sdf 是线程不安全的, 不能保证一个线程, 一个 sdf 对象, 所以在这个例子中 sdf 是共享资源, sdf 可能被其中一个子类进行了修改, 所以无法保证 foo 的 sdf 是绝对线程安全的
模拟售票系统出现的线程安全问题
@Slf4j
public class exerciseSell {
@Test
public void test() throws Exception {
// 模拟存在多少张票
TicketWindow ticketWindow = new TicketWindow(2000);
CountDownLatch latch = new CountDownLatch(2000);
// 记录每次售出多少张票
List<Integer> amountList = new Vector<>();
// List<Integer> amountList = new ArrayList<>(); // 线程不安全的
for (int i = 0; i < 2000; i++) {
Thread thread = new Thread(() -> {
int amount = ticketWindow.sell(randomAmount());
amountList.add(amount);
latch.countDown();
});
thread.start();
}
latch.await();
log.debug("余票: {}", ticketWindow.getCount());
log.debug("卖出的票数: {}", amountList.stream().mapToInt(value -> value).sum());
}
private Random random = new Random();
private int randomAmount() {
return random.nextInt(5) + 1;
}
}
/**
* 售票窗口
*/
class TicketWindow {
// private int count;
// 防止指令重排
// (1) 共享资源
private volatile int count;
TicketWindow(int count) {
this.count = count;
}
int getCount() {
return count;
}
/**
* 售票
*
* @param amount
*
* @return
*/
synchronized int sell(int amount) {
// int sell(int amount) { // 线程不安全
// (2) 读写共享资源
if (this.count >= amount) {
this.count -= amount;
return amount;
}
else {
return 0;
}
}
}
多线程行使转账的案例
@Slf4j
public class ExerciseTransfer {
@Test
public void test() throws Exception {
CountDownLatch latch = new CountDownLatch(200);
Account a = new Account(10000);
Account b = new Account(10000);
for (int i = 0; i < 100; i++) {
new Thread(() -> {
a.transfer(b, randomAmount());
latch.countDown();
}).start();
}
for (int i = 0; i < 100; i++) {
new Thread(() -> {
b.transfer(a, randomAmount());
latch.countDown();
}).start();
}
latch.await();
log.debug("total: {}", a.getMoney() + b.getMoney());
}
private static Random random = new Random();
private static int randomAmount() {
return random.nextInt(100) + 1;
}
}
/**
* 账户
*/
class Account {
// 防止指令重排问题
private volatile int money;
int getMoney() {
return money;
}
private void setMoney(int money) {
this.money = money;
}
Account(int money) {
this.money = money;
}
/**
* 转账
*
* @param target
* @param money
*/
void transfer(Account target, int money) {
if (this.money >= money) {
// 对象锁是类名
synchronized (Account.class) {
if (this.money >= money) {
this.setMoney(this.getMoney() - money);
target.setMoney(target.getMoney() + money);
}
}
}
}
}