Java标准库中的线程安全类
Java标准库中的很多类都是不安全的,这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施,如下,这几个类都是线程不安全的。

但也还有一些线程安全的,使用了一些锁机制来控制。

这几个类,自带了锁,在多线程环境下,情况可以好一些,但也不是100%不会出问题,只是概率比上面没有自带锁的类小很多,但仍然需要具体代码具体分析。只是概率比上面没有自带锁的小很多,但仍需要具体代码具体分析。(多线程的代码,稍微有一些变化,就可能会产生不一样的结果)
Vector,HashTable,StringBuffer这几个类,都是属于标准库即将启用了,不推荐使用,因为要保持和老代码进行兼容,所以暂时还留着,但是未来某一天新版本的JDK,就有可能把这些内容都删掉了。
String类虽然没有加锁,但由于不涉及修改,仍然是线程安全的
String是一个不可变对象
好处:1、方便JVM进行缓存(直接放到字符串常量池中)。
2、hash值是固定的。
3、线程安全的。
为什么String是线程安全的呢?
1、String持有的数据(char数组 或者 byte数组(受JDK版本影响)是private)。
2、没有提供public的方法来修改byte数组的内容。
线程安全问题内存可见性
之前我们提到了,引起线程安全问题的原因还有内存可见性。
如果一个线程负责写,一个线程负责度,这个时候是否也会有线程安全问题呢?
示例代码:
import java.util.Scanner;
//内存可见性问题
//t2修改了内存,t1没有看到内存的变化
public class ThreadDemo23 {
private static int flag = 0;//volitile 保证内存可见性
public static void main(String[] args) {
Thread t1 = new Thread(()->{
//在输入之前flag==0已经在寄存器上load很多次了,所以JVM会自动把load这条指令视为多余指令,将load忽略掉,将读取内存操作,变为读取寄存器操作
while(flag==0){
}
System.out.println("t1进程结束!!!");
});
Thread t2 = new Thread(()->{
System.out.println("请输入flag的值:");
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
上面的代码,预期通过t2线程输入的整数,控制t1线程的结束(只要不是0就能结束),只要在t2线程输入的不为0就可以使t1线程结束。
但当我们输入一个1的时候,发现t1并没有真的结束(没有打印“t1线程结束”),也就是我们当前代码出现了bug!!!
注意:我们当前的问题,并非和“指令是不是原子的”有关,而是和内存可见性问题有关。
这段代码,核心指令是两条:
1、load读取内存中flg的值到CPU寄存器中。
2、拿着寄存器的值和0进行比较(条件跳转指令)。
由于上面的循环体中,什么都没有写,循环执行的速度会非常快,也就会快速地执行上述两条指令。
在执行过程中,由于load操作执行的结果,每次都是一样的(我们在t2线程中想要输入一个其他的值是要几秒后才能输入),这种几秒之内,load操作已经不知道执行了多少次循环了(上百亿次)。load操作所花费的开销是远远超过比较的开销的。
频繁地执行load和条件跳转,load的开销比较大,并且load的结果又没有什么变化。所以,此时JVM就会产生怀疑,怀疑我们这里的load操作,是否真的有存在的必要?
JVM就可能会做出代码优化操作,把上述load操作给优化掉(只有前几次load,后续发现,反之load的结果都一样,静态分析代码,也没有看到后续有操作对flag进行了修改,因此直接就激进地把load操作给干掉了),干掉load操作后,就相当于,不再重复读内存了,直接读寄存器或者缓的值(工作内存)(读工作内存地执行速度会更快些,能够大幅度减少开销),从而大幅度提高了循环的执行速度。
编译器 / JVM 是非常厉害的,很多地方都会涉及到代码优化。
虽然我们程序员是写出了一份代码,但编译器和 JVM 能智能的分析出,我们当前写的代码哪里不太合理,对我们的代码进行调整,在保持原有逻辑不变的情况下(这个事情,想让编译器保持正确,没有那么容易,如果是单线程,还可以,如果是多线程,编译器和 JVM 是很容易出现误判的!!!),提高编程效率。
而我们上述出现的情况,就是可以视为 bug,在多线程的情况下出现了误判。
刚才的代码,就是因为t2修改了内存中的数据,但是t1读的是工作内存(CPU寄存器或者缓存) 中的数据,并没有看到内存中的变化,这就是内存可见性问题。
内存可见性问题,是高度依赖编译器优化的具体实现的。编译器啥时候触发优化,啥时候不触发优化优化,是不确定的。上述代码中,如果稍微进行一点修改,结果可能就截然不同了。
比如,我在循环里让线程休眠1ms,进程就能正常执行了。
//内存可见性问题
//t2修改了内存,t1没有看到内存的变化
public class ThreadDemo23 {
private static int flag = 0;//volitile 保证内存可见性
public static void main(String[] args) {
Thread t1 = new Thread(()->{
//在输入之前flag==0已经在寄存器上load很多次了,所以JVM会自动把load这条指令视为多余指令,将load忽略掉,将读取内存操作,变为读取寄存器操作
while(flag==0){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("t1进程结束!!!");
});
Thread t2 = new Thread(()->{
System.out.println("请输入flag的值:");
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}

如上图代码,不加 sleep,一秒钟就可以循环上百亿次,load 操作的整体开销是非常大的,优化的迫切程度就更高了。加了 sleep,一秒钟循环 1000 次,load 整体开销就没那么大了,优化的迫切程度就降低了,也就可以实现我们预期的结果了。
所以,内存可见性问题,其实是一个高度依赖编译器优化问题,啥时候触发这个问题,啥时候不触发,不确定……
我们更希望的是:我们的代码,100%不出现问题,即无论当前这个线程代码是怎么写的,不论有没有sleep,都不要出现这种内存可见性问题。
volatile关键字
Java提供了volatile(易变的)就可以使得上述优化操作被强制关闭,就可以确保每次循环都会重新从内存中读取数据了,此时,开销是变大了,效率是降低了,但是数据的准确性/逻辑的正确性提高了。
引入volatile关键字,其实是把选择权,交给了程序员自己,当现在的情况,是需要更高的效率,不要求十分的准确度时,就可以使用默认情况,而不是用volatile关键字,但是,当现在的情况,需要十分准确的时候,就需要使用volatile。
volatile 关键字,其中一个核心功能,就是保证内存可见性(另一个功能是:禁止指令重排序,这个我们后面再讲)。
此处谈到的 volatile,内存可见性问题,网上的资料,谈到一个词,JMM(Java Memory Model)。
我们上面的表述:
在上述代码中,编译器发现,每次循环都要读取内存,开销太大,于是就把读取内存的操作优化成读取寄存器的操作,从而提高效率。
在 JMM 模型下表述:
在上述代码中,编译器发现,每次循环都要读取”主内存“,开销太大,于是就把数据从”主内存“中复制到”工作内存“中,后续每次都是读取”工作内存“。
JMM 是 Java 规范文档上提到的一个概念,本身就是一个抽象的概念。
为什么引入 JMM(主内存,工作内存)这一套概念呢,而不是直接说 CPU 寄存器呢?主要是为了”跨平台“
作为规范文档,要严谨表述,每次都说,优化到 CPU寄存器或者缓存中(非常拗口),而且,不同的 CPU,寄存器情况还不一样,缓存有没有也不一样,缓存有几级也不一样,变数比较多,Java 文档为例严谨表述,就引入了”工作内存“的概念,代指 CPU 寄存器 + 缓存这一套东西...
我们给刚才出现“内存可见性问题”的代码的flag加上volatile就可以解决读到内存的变化了。
public class ThreadDemo23 {
private volatile static int flag = 0;//volitile 保证内存可见性
public static void main(String[] args) {
Thread t1 = new Thread(()->{
//在输入之前flag==0已经在寄存器上load很多次了,所以JVM会自动把load这条指令视为多余指令,将load忽略掉,将读取内存操作,变为读取寄存器操作
while(flag==0){
// try {
// Thread.sleep(1);
// } catch (InterruptedException e) {
// throw new RuntimeException(e);
// }
}
System.out.println("t1进程结束!!!");
});
Thread t2 = new Thread(()->{
System.out.println("请输入flag的值:");
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}


387

被折叠的 条评论
为什么被折叠?



