什么是线程安全性
《Java Concurrency In Practice》对线程安全的定义如下:
当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步及在调用方代码不作其他的协调,这个类的行为仍是正确的,那么称这个类是线程安全的。
简单理解就是,多线程环境下访问这个对象,我们不必考虑对象里面方法间协调的问题,对象的行为依然正确。通常这些类自己已经做好了同步等工作,比如,并发访问对象的共享变量时加上锁等同步机制。
多线程环境下,如果没有正确的同步机制,非常容易出错,比如非原子性操作导致的并发操作结果错误、线程运行顺序导致的程序错误、线程活跃性,如死锁、活锁、线程饥饿等都会导致程序结果未达预期。下面简单介绍下各种情况导致的错误及解决方案。
原子性
非原子操作在多线程程序中出错经典的例子,如,i++操作。看似一个操作,实际分成了三步,先从内存中读取,然后增加,最后写回内存。下面看个例子:
/**
* @author kangming.ning
* @date 2023-02-16
* @since 1.0
**/
public class SelfAddTest {
static int i;
public static void main(String[] args) throws InterruptedException {
Runnable runnable=()->{
selfAdd();
};
Thread thread1=new Thread(runnable);
thread1.start();
Thread thread2=new Thread(runnable);
thread2.start();
thread1.join();
thread2.join();
System.out.println(i);
}
private static void selfAdd(){
for (int j=0;j<10000;j++){
i++;
}
}
}
结果打印不定,通常小于预期值2000。这说明程序出错了。下面我们反编译class文件,看生成的汇编代码,看看i++到底生成了哪些指令(javap )
输入指令:javap -verbose -c -l -private .\SelfAddTest.class
Last modified 2023-2-17; size 1614 bytes
MD5 checksum 96b2b83020dc44d8755a9c241ba70d76
Compiled from "SelfAddTest.java"
public class com.kmning.wallet.myconcurrent.SelfAddTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #12.#39 // java/lang/Object."<init>":()V
#2 = InvokeDynamic #0:#44 // #0:run:()Ljava/lang/Runnable;
#3 = Class #45 // java/lang/Thread
#4 = Methodref #3.#46 // java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
#5 = Methodref #3.#47 // java/lang/Thread.start:()V
#6 = Methodref #3.#48 // java/lang/Thread.join:()V
#7 = Fieldref #49.#50 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Fieldref #11.#51 // com/kmning/wallet/myconcurrent/SelfAddTest.i:I
#9 = Methodref #52.#53 // java/io/PrintStream.println:(I)V
#10 = Methodref #11.#54 // com/kmning/wallet/myconcurrent/SelfAddTest.selfAdd:()V
#11 = Class #55 // com/kmning/wallet/myconcurrent/SelfAddTest
#12 = Class #56 // java/lang/Object
#13 = Utf8 i
#14 = Utf8 I
#15 = Utf8 <init>
#16 = Utf8 ()V
#17 = Utf8 Code
#18 = Utf8 LineNumberTable
#19 = Utf8 LocalVariableTable
#20 = Utf8 this
#21 = Utf8 Lcom/kmning/wallet/myconcurrent/SelfAddTest;
#22 = Utf8 main
#23 = Utf8 ([Ljava/lang/String;)V
#24 = Utf8 args
#25 = Utf8 [Ljava/lang/String;
#26 = Utf8 runnable
#27 = Utf8 Ljava/lang/Runnable;
#28 = Utf8 thread1
#29 = Utf8 Ljava/lang/Thread;
#30 = Utf8 thread2
#31 = Utf8 Exceptions
#32 = Class #57 // java/lang/InterruptedException
#33 = Utf8 selfAdd
#34 = Utf8 j
#35 = Utf8 StackMapTable
#36 = Utf8 lambda$main$0
#37 = Utf8 SourceFile
#38 = Utf8 SelfAddTest.java
#39 = NameAndType #15:#16 // "<init>":()V
#40 = Utf8 BootstrapMethods
#41 = MethodHandle #6:#58 // invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invo
ke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
#42 = MethodType #16 // ()V
#43 = MethodHandle #6:#59 // invokestatic com/kmning/wallet/myconcurrent/SelfAddTest.lambda$main$0:()V
#44 = NameAndType #60:#61 // run:()Ljava/lang/Runnable;
#45 = Utf8 java/lang/Thread
#46 = NameAndType #15:#62 // "<init>":(Ljava/lang/Runnable;)V
#47 = NameAndType #63:#16 // start:()V
#48 = NameAndType #64:#16 // join:()V
#49 = Class #65 // java/lang/System
#50 = NameAndType #66:#67 // out:Ljava/io/PrintStream;
#51 = NameAndType #13:#14 // i:I
#52 = Class #68 // java/io/PrintStream
#53 = NameAndType #69:#70 // println:(I)V
#54 = NameAndType #33:#16 // selfAdd:()V
#55 = Utf8 com/kmning/wallet/myconcurrent/SelfAddTest
#56 = Utf8 java/lang/Object
#57 = Utf8 java/lang/InterruptedException
#58 = Methodref #71.#72 // java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType
;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
#59 = Methodref #11.#73 // com/kmning/wallet/myconcurrent/SelfAddTest.lambda$main$0:()V
#60 = Utf8 run
#61 = Utf8 ()Ljava/lang/Runnable;
#62 = Utf8 (Ljava/lang/Runnable;)V
#63 = Utf8 start
#64 = Utf8 join
#65 = Utf8 java/lang/System
#66 = Utf8 out
#67 = Utf8 Ljava/io/PrintStream;
#68 = Utf8 java/io/PrintStream
#69 = Utf8 println
#70 = Utf8 (I)V
#71 = Class #74 // java/lang/invoke/LambdaMetafactory
#72 = NameAndType #75:#79 // metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Lja
va/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
#73 = NameAndType #36:#16 // lambda$main$0:()V
#74 = Utf8 java/lang/invoke/LambdaMetafactory
#75 = Utf8 metafactory
#76 = Class #81 // java/lang/invoke/MethodHandles$Lookup
#77 = Utf8 Lookup
#78 = Utf8 InnerClasses
#79 = Utf8 (Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Lja
va/lang/invoke/CallSite;
#80 = Class #82 // java/lang/invoke/MethodHandles
#81 = Utf8 java/lang/invoke/MethodHandles$Lookup
#82 = Utf8 java/lang/invoke/MethodHandles
{
static int i;
descriptor: I
flags: ACC_STATIC
public com.kmning.wallet.myconcurrent.SelfAddTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/kmning/wallet/myconcurrent/SelfAddTest;
public static void main(java.lang.String[]) throws java.lang.InterruptedException;
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=4, args_size=1
0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: new #3 // class java/lang/Thread
9: dup
10: aload_1
11: invokespecial #4 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
14: astore_2
15: aload_2
16: invokevirtual #5 // Method java/lang/Thread.start:()V
19: new #3 // class java/lang/Thread
22: dup
23: aload_1
24: invokespecial #4 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
27: astore_3
28: aload_3
29: invokevirtual #5 // Method java/lang/Thread.start:()V
32: aload_2
33: invokevirtual #6 // Method java/lang/Thread.join:()V
36: aload_3
37: invokevirtual #6 // Method java/lang/Thread.join:()V
40: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
43: getstatic #8 // Field i:I
46: invokevirtual #9 // Method java/io/PrintStream.println:(I)V
49: return
LineNumberTable:
line 13: 0
line 17: 6
line 18: 15
line 19: 19
line 20: 28
line 21: 32
line 22: 36
line 23: 40
line 24: 49
LocalVariableTable:
Start Length Slot Name Signature
0 50 0 args [Ljava/lang/String;
6 44 1 runnable Ljava/lang/Runnable;
15 35 2 thread1 Ljava/lang/Thread;
28 22 3 thread2 Ljava/lang/Thread;
Exceptions:
throws java.lang.InterruptedException
private static void selfAdd();
descriptor: ()V
flags: ACC_PRIVATE, ACC_STATIC
Code:
stack=2, locals=1, args_size=0
0: iconst_0
1: istore_0
2: iload_0
3: sipush 10000
6: if_icmpge 23
9: getstatic #8 // Field i:I
12: iconst_1
13: iadd
14: putstatic #8 // Field i:I
17: iinc 0, 1
20: goto 2
23: return
LineNumberTable:
line 27: 0
line 28: 9
line 27: 17
line 30: 23
LocalVariableTable:
Start Length Slot Name Signature
2 21 0 j I
StackMapTable: number_of_entries = 2
frame_type = 252 /* append */
offset_delta = 2
locals = [ int ]
frame_type = 250 /* chop */
offset_delta = 20
private static void lambda$main$0();
descriptor: ()V
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=0, locals=0, args_size=0
0: invokestatic #10 // Method selfAdd:()V
3: return
LineNumberTable:
line 14: 0
line 15: 3
}
SourceFile: "SelfAddTest.java"
InnerClasses:
public static final #77= #76 of #80; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
0: #41 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/Meth
odHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#42 ()V
#43 invokestatic com/kmning/wallet/myconcurrent/SelfAddTest.lambda$main$0:()V
#42 ()V
javap指令说明:
-help 输出 javap 的帮助信息。
-l 输出行及局部变量表。
-b 确保与 JDK 1.1 javap 的向后兼容性。
-public 只显示 public 类及成员。
-protected 只显示 protected 和 public 类及成员。
-package 只显示包、protected 和 public 类及成员。这是缺省设置。
-private 显示所有类和成员。
-J[flag] 直接将 flag 传给运行时系统。
-s 输出内部类型签名。
-c 输出类中各方法的未解析的代码,即构成 Java 字节码的指令。
-verbose 输出堆栈大小、各方法的 locals 及 args 数,以及class文件的编译版本
通过以上的输出,我们关注几行重要的字节码指令
9: getstatic #8 // Field i:I
12: iconst_1
13: iadd
14: putstatic #8 // Field i:I
getstatic指令把静态变量i的值读取出来压入栈,iconst_1指令把常量1压入栈,iadd指令把栈顶两个元素执行相加操作。最后通过putstatic把i相加后的结果同步回主内存中。这里的操作明显不是原子操作,当指令在执行iconst_1、iadd这些指令的时候,其他线程可能已经把i的值改变了。所以出现了线程安全问题。我们知道volatile关键字能保证变量的值在读取的那刻会是最新的,但是读取到操作栈顶后,其他线程对该值的更新,此时当前线程也不会更新,所以上面的代码就是加了volatile关键字也不能保证程序的正确,可行的办法是同步锁。
竞争条件
多线程运行,竞争条件会导致程序出现非预期结果。最常见的竞争条件是“检查再运行”。下面看个获取单例非线程安全的反倒:
/**
* @author kangming.ning
* @date 2023-02-17
* @since 1.0
**/
@NotThreadSafe
public class WrongSingleton {
private static WrongSingleton instance = null;
private WrongSingleton() {
}
public static WrongSingleton getInstance() {
if (null == instance) {
instance = new WrongSingleton();
}
return instance;
}
}
在getInstance函数中,先判断实例是否有空,如果是空,则实例化对象并赋值,最后返回对象。在单线程程序中,这段代码是不会出现问题的。但是在多线程环境中,却可能会出现不同线程获取到不同的对象,而我们希望的是只提供一个对象。有可能出现多个线程同时执行到判断instance是否为空的代码处,多个线程判断结果都是空,因此出现对象的多次实例化。
下面尝试优化,通过锁来保护状态。
@NotThreadSafe
public class WrongSingleton1 {
private static WrongSingleton1 instance = null;
private final static Object lock = new Object();
private WrongSingleton1() {
}
public static WrongSingleton1 getInstance() {
if (null == instance) {
synchronized (lock) {
//锁在里面,可能多个线程同时进入到第一个null判断里面,所以这里还得判断一次
if (null == instance) {
instance = new WrongSingleton1();
}
}
}
return instance;
}
}
这段代码相对于上面的代码已有很有改进,synchronized锁不加到方法签名是为了避免在对象实例化后,后续还需要抢占monotor锁以获取对象,这样是不可接受,没必要的性能浪费。进入方法后,判断如果对象不是空直接返回,没任何多余的锁开销。如果是空,此时需要monotor对象锁来帮忙做互斥。注意到进入同步代码块后还写了一个判断,这是因为多线程环境,有可能已经有多个线程判断到对象为空,在排队等待monitor锁,因此,里面的判断必不可少。
做到这个程度应该没问题了吧?看起来确实如此,但遗憾的是,上面的代码在多线程环境下依然会有问题。问题出在下面这行代码
instance = new WrongSingleton1();
这段代码,也是非原子操作,它可以分成3个字节码指令
- 分配内存 memory = allocate()
- 初始化对象 instance(memory)
- instace = memory
第二步和第三步的执行顺序不是固定的,这是由于编译器和处理器的优化会进行指令重排序。假如先执行了赋值,再进行初始化,此时有的线程就会在第一个判断内发现对象不是空,直接拿到了那个对象,但那个对象的初始化还没执行,因此又出现了线程安全问题。可见,多线程程序虽然诸多好处,但同时也给编程带来了复杂性。
那么,如果解决上面的问题?其实就是阻止指令重排序即可,只需要给引用变量添加保证值可见性的volatile关键字即可。
多线程程序,单例的正确使用示例:
@ThreadSafe
public final class RrightSingleton {
private volatile static RrightSingleton instance = null;
private final static Object lock = new Object();
private RrightSingleton() {
}
public static RrightSingleton getInstance() {
if (null == instance) {
synchronized (lock) {
//锁在里面,可能多个线程同时进入到第一个null判断里面,所以这里还得判断一次
if (null == instance) {
instance = new RrightSingleton();
}
}
}
return instance;
}
}
活跃性问题
活跃性导致的线程安全问题通常有三种:
- 死锁
- 活锁
- 线程饥饿
死锁
最常见的活跃性问题是死锁,死锁是指两个线程之间相互等待对方资源,但同时又互不相让。下面看一个死锁的例子
/**
* @author kangming.ning
* @date 2023-02-17
* @since 1.0
**/
public class DeadLockTest {
public static void test1(Object lock1, Object lock2) throws InterruptedException {
synchronized (lock1) {
Thread.sleep(100);
synchronized (lock2) {
System.out.println(Thread.currentThread() + " 获取到了两把monitor锁");
}
}
}
public static void main(String[] args) {
final Object lock1 = new Object();
final Object lock2 = new Object();
Runnable r1 = () -> {
try {
test1(lock1, lock2);
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Runnable r2 = () -> {
try {
//这里将参数调换了一下
test1(lock2, lock1);
} catch (InterruptedException e) {
e.printStackTrace();
}
};
new Thread(r1).start();
new Thread(r2).start();
}
}
运行上面的程序会发现,程序不能结束,也不打印任何东西。这说明已经出现了死锁。
活锁
活锁与死锁非常相似,也是程序一直等不到结果。比如消息队列的异常重试机制,如果没设置重试次数,能永久重试,但可能是因为消息数据的问题导致重试多少次都不能解决问题,此时可以认为是出现了活锁问题。
线程饥饿
Java线程有优先级,如果线程优先级过低,这条线程有可能无法分配到CPU资源。此时认为是出现了饥饿问题。还有一种情况是,某线程始终持有锁,但一直不释放,这样也可以认为是线程饥饿。