<JavaEE><多线程>线程安全问题

目录

一、线程安全概述

1.1什么是线程安全

1.2出现线程安全问题的根本原因

1.3不安全线程案例

二、产生线程安全问题的原因

2.1原子性

2.2可见性

2.3指令重排序

三、线程加锁 和 volatile关键字

3.1线程加锁

(1)互斥性

(2)可重入性

(3)synchronized的使用

(4)解决原子性、可见性线程安全问题案例

3.2 volatile关键字

四、线程安全的标准类


一、线程安全概述

1.1什么是线程安全

我们的代码无论是在单线程情况下,还是在多线程情况下,都不会产生bug,则我们称该部分代码是“线程安全”的。

反之,若代码在单线程情况下运行无误,在多线程情况下产生了bug,则这部分代码就有线程安全问题,称之为“线程不安全”或“存在线程安全问题”。

1.2出现线程安全问题的根本原因

我们知道线程是系统调度的基本单位,而操作系统中进程调度采用的是抢占式执行的策略。在这一策略下,多线程程序在进行线程调度时线程的执行循序是不确定的(例如可能在执行某个线程时突然中断,执行起另一个线程的代码,并反复),这种代码执行循序的不确定性就可能导致种种问题,产生bug。

线程安全问题的解决就是要让无数种可能出现的情况都能达到我们所预期的效果,但凡有任何一种可能会产生bug,都不叫线程安全。

1.3不安全线程案例

public class ThreadDemo1 {
    static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for(int i = 0 ; i < 100_000_000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0 ; i < 100_000_000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

案例中,我们同时创建两个线程,让他们都对静态变量count执行一亿次“++”操作,最终得到如下结果:

<JavaEE><多线程>线程安全问题_第1张图片

“100096053”显然和我们的预期结果大不相同,没有达到预期的“2000000000”,由此可知该多线程程序有bug。

二、产生线程安全问题的原因

2.1原子性

原子性,即一个事物要么完整执行,要么不执行,整个事务作为一个整体而存在。

上述案例的两个线程本质都是不断在执行count++,就算由于线程的调度而产生不同执行顺序,在逻辑上也并不会产生什么问题,按理说更不会产生“100096053”这个匪夷所思的结果。

Java是高级语言,是需要经过编译之后才能执行的,也就是说我们所看到的代码和执行的代码往往是不一样的,即⼀条java语句不⼀定是原⼦的,也不⼀定只是⼀条指令,如count++就可以分解为三个指令:

  1. 将数据从内存读取到CPU寄存器
  2. 对数据进行更新(自增)
  3. 将更新后的数据写回原内存

这样,原本看起来不会受执行顺序影响的代码就将问题暴露出来了:

<JavaEE><多线程>线程安全问题_第2张图片

<JavaEE><多线程>线程安全问题_第3张图片

由于两个线程都是对同一份内存进行操作,在非理想情况下,可能1、可能2、可能3(还有其他可能)实际效果都只相当于仅进行一次++操作,这也是结果会等于“100096053”的原因。

要解决原子性的问题,就需要赋予cout++操作原子性,让count++操作成为一个整体,一次要么执行完,要么不执行,要做到这点就需要通过加操作来完成。

2.2可见性

<JavaEE><多线程>线程安全问题_第4张图片

  • 线程之间的共享变量存在主内存
  • 每一个线程都有自己的工作内存
  • 当线程要读取一个共享变量的时候,首先会把变量从主内存拷贝到工作内存,再从工作内存读取数据
  • 当线程要修改一个共享变量的时候,也会先修改工作内存中的副本,再同步给主内存

就++操作分解出的三个指令可以得到如下过程,将主内存中共享变量count的值读到线程的工作内存(寄存器)中,随后修改工作内存中count的拷贝,最后再将工作内存中的值写回到主内存,这时count的值才被修改为count+1。

由此,上文原子性介绍的可能1,分析主内存和工作内存随指令执行产生的变化,可以得到如下图解:

<JavaEE><多线程>线程安全问题_第5张图片

可以观察到,由于主内存中的count是一个共享变量,线程1和线程2都可以访问到,因此线程2在线程1未完成++操作时就读取了count,导致两个线程都是在将cout从0变为1,效果仅相当于执行一次++操作。

2.3指令重排序

指令重排序是因为编译器会在保持“代码逻辑不发生变化”这一前提下对我们的代码进行优化,举个形象的例子:

1.去楼下超市买菜

2.回家

3.下楼倒垃圾

假如我们的代码执行逻辑为1->2->3,代码优化过后可能执行逻辑就变为1->3->2,两种执行逻辑效果相同,但效率却大大提高了。

而在多线程代码中,代码优化却可能会导致bug的出现。例如当线程频繁对同一个变量进行读值,在代码优化过后可能就不会再从主内存中读值,而是直接从线程的寄存器中读值,这时如果修改主内存的值,线程是感知不到的,从而导致线程安全问题的出现。

public class ThreadDemo2 {
    static int f = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(f == 0) {
            }
            System.out.println("t1 线程结束");
        });
        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            f = 1;
            System.out.println("t2 线程结束");
        });
        t1.start();
        t2.start();
    }
}

上述代码是在t1线程运行3秒后修改f的值使t1线程结束,然而三秒后运行结果如下:

<JavaEE><多线程>线程安全问题_第6张图片

可以观察到,用来修改f值的t2线程结束后程序并没有停止运行,也就是说t1线程还没有结束,f值的修改并没有对t1线程产生影响。

三、线程加锁 和 volatile关键字

3.1线程加锁

生活中我们在上厕所时可以观察到,厕所的门上有“有人”、“空”等字样或颜色变化来表示厕所当前是否被占用,若厕所当前有人占用,里面的人会对门上锁,使其他人不能在自己上厕所的期间也来占用厕所,只能等自己主动解锁后才能来争夺厕所的使用权。

<JavaEE><多线程>线程安全问题_第7张图片

多线程代码中,每个线程都相当于一个人,厕所就相当于一个对象,当一个线程对该对象进行加锁时,其他线程也想要对该对象加锁则只能等到加锁线程解锁后才能尝试对该对象进行加锁。这样一来,就可以避免线程对同一内存资源调用而产生的bug等问题。

public class ThreadDemo2 {
    static Object lock = null;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized(lock) {
                //代码块
            }
            System.out.println("t1 线程结束");
        });

        t1.start();

    }
}

Java中使用synchronized关键字修饰代码块,执行到该语句对线程进行加锁,退出synchronized修饰的代码块对线程进行解锁,synchronized的特性如下。

(1)互斥性

synchronized加锁会起到互斥效果,某个线程执行到某个对象的synchronized代码块中时,其他线程如果也执行到同⼀个对象的synchronized代码块就会阻塞等待。简而言之,就是多个线程竞争同一把锁就会阻塞。

上⼀个线程解锁之后,下⼀个线程并不是⽴即就能获取到锁,⽽是要靠操作系统来"唤醒",这也就是操作系统线程调度的⼀部分⼯作。

假设有ABC三个线程,线程A先获取到锁,然后B尝试获取锁,然后C再尝试获取锁,此时B和C都在阻塞队列中排队等待。但是当A释放锁之后,虽然B⽐C先来的,但是B不⼀定就能获取到锁,而是和C重新竞争,并不遵守先来后到的规则

下例中,t1线程和t2线程的synchronized都对lock对象进行加锁,由于t1线程先执行,首先对lock对象加锁,所以t2线程想要执行synchronized代码块就必须阻塞等待至t1线程的synchronized代码块结束为止。

public class ThreadDemo2 {
    static Object lock = 1;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            System.out.println("t1 线程开始");
            synchronized(lock) {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t1 线程结束");
        });

        Thread t2 = new Thread(() -> {
            System.out.println("t2 线程开始");
           synchronized (lock){
               ;
           }
            System.out.println("t2 线程结束");
        });

        long stime = System.currentTimeMillis();
        t1.start();
        t2.start();
        t2.join();
        System.out.println("两个线程执行完消耗的时间:" + (System.currentTimeMillis() - stime) + "毫秒");
    }
}

执行结果如下:

<JavaEE><多线程>线程安全问题_第8张图片

(2)可重入性

Java的锁是可重入的,即如果某个线程加锁的时候,发现锁已经被⼈占用,但是恰好占⽤的正是自己,那么仍然可以继续获取到锁,并让计数器自增。解锁则对计数器自减,解锁的时候计数器递减为0的时候,才真正释放锁(才能被别的线程获取到)。

public class ThreadDemo3 {
    static Object lock1 = 0;
    static Object lock2 = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("锁计数器 1");
                synchronized (lock2) {
                    System.out.println("锁计数器 2");
                }
                System.out.println("锁计数器 1");
            }
            System.out.println("解锁");
        });
    }
}
(3)synchronized的使用

明确:多个线程竞争同一把锁就会阻塞

1.修饰代码块,对指定对象加锁

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            //锁任意对象
            synchronized (lock) {
                
            }
        });
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            //锁当前对象
            synchronized (this) {
                
            }
        });
    }

2.修饰普通方法,对当前对象进行加锁

对SynchronizedDemo对象进行加锁

public class SynchronizedDemo {
    public synchronized void test() {
        
    }
}

3.修饰静态方法,对当前类对象进行加锁

对SynchronizedDemo类进行加锁

public class SynchronizedDemo {
    public synchronized static void test() {
        
    }
}
(4)解决原子性、可见性线程安全问题案例

对于非原子性的count++操作,我们通过线程加锁的方法使cout++在同一时间只能执行一个,赋予count++原子性,进而避免bug的产生

public class ThreadDemo4 {
    static int count = 0;
    static Object lock = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for(int i = 0 ; i < 100_000_000; i++) {
                synchronized (lock) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0 ; i < 100_000_000; i++) {
                synchronized (lock) {
                    count++;
                }
            }
        });
        t1.start();
        t1.join();
        t2.start();
        t2.join();
        System.out.println(count);

        char[] arr = "jijsaf".toCharArray();
    }
}

<JavaEE><多线程>线程安全问题_第9张图片

3.2 volatile关键字

volatile修饰的变量,能够保证"内存可见性",即保证每次读取该变量时都要重新读取内存内容。使用volatile关键字修饰,可以避免重复读取编译器进行代码优化而产生的bug(指令重排序问题)。

public class ThreadDemo5 {
    static volatile int f = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (f == 0) {
            }
            System.out.println("t1 线程结束");
        });
        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            f=1;
            System.out.println("t2 线程结束");
        });
        t1.start();
        t2.start();
    }
}

<JavaEE><多线程>线程安全问题_第10张图片

可以看到,本次t1线程顺利结束了。

四、线程安全的标准类

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

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

但是还有⼀些是线程安全的,使⽤了⼀些锁机制来控制。

  • Vector(不推荐使⽤)
  • HashTable(不推荐使⽤)
  • ConcurrentHashMap
  • StringBuffer

<JavaEE><多线程>线程安全问题_第11张图片

还有的虽然没有加锁,但是不涉及"修改",仍然是线程安全的

  • String

博主是Java新人,每位同志的支持都会给博主莫大的动力,如果有任何疑问,或者发现了任何错误,都欢迎大家在评论区交流“ψ(`∇´)ψ

你可能感兴趣的:(JavaEE,java,开发语言,java-ee,安全)