一文读懂Java线程安全

什么是线程安全性

《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个字节码指令

  1. 分配内存 memory = allocate()
  2. 初始化对象 instance(memory)
  3. 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资源。此时认为是出现了饥饿问题。还有一种情况是,某线程始终持有锁,但一直不释放,这样也可以认为是线程饥饿。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值