Skip to content

JUC 多线程安全问题

本文介绍在Java多线程开发过程中会出现的安全问题。

1. 多线程问题

现有如下代码:count初始化为0,线程t1对count执行5000次自增操作,线程t2对count执行5000次自减操作,线程t1和t2运行结束后,count的值应该为0。

java
public class MultiThreadTest04 {

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 =
            new Thread("t1") {
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    count ++;
                }
            }
        };
        t1.start();

        Thread t2 = new Thread("t2"){
            @Override
            public void run() {
                for (int i = 0;i<5000;i++){
                    count --;
                }
            }
        };
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

但是,实际执行这段代码,count的结果并不总是为0,有时会出现正数或者负数,这就是多线程的问题所在。

Java中对静态变量的自增、自减操作并不是原子操作,从字节码角度分析,自增自减操作各自包含四条指令:

txt
getstatic        i      #获取静态变量i
iconst_1                #准备常量1
iadd                    #自增
putstatic        i      #将修改后的值存入静态变量i复制复制失败复制成功
txt
getstatic        i      #获取静态变量i
iconst_1                #准备常量1
isub                    #自减
putstatic        i      #将修改后的值存入静态变量i复制复制失败复制成功

而Java的内存模型如下,完成静态变量的自增、自减需要在主存和工作内存之间进行数据交换:

image-20200822202207414

如果是单线程,以上八行字节码指令是顺序执行的,则不会出错:

image-20200822202342342

但是,如果是多线程,由于线程切换的原因,以上八行字节码可能交错运行,错误就会出现:

出现i=-1的情况:

image-20200822202451898

出现i=1的情况:

image-20200822202638567

2. 临界区与竞态条件

在同一程序中运行多个线程本身不会导致问题,如果多个线程访问了相同的资源(如内存区、文件等),则可能会导致问题。

  • 如果多个线程只是对共享资源进行读取,不会出现问题;
  • 如果多个线程对共享资源进行读写,则会出现问题。

**临界区:**一段代码对共享资源进行多线程读写,则该段代码称为临界区;

**竞态条件:**多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,则发生了竞态条件。

3. 线程安全分析

上节阐述了多个线程对共享资源进行读写才会发生安全问题,本小节按情况说明。

3.1 静态变量

  • 如果多线程没有共享静态成员变量,则不会引发线程安全问题;
  • 如果多线程共享了静态成员变量,则分为两种情况:
    • 多线程只有读取操作,不会引发线程安全问题;
    • 多线程有读写操作,则会产生竞态条件,会引发线程安全问题;

例如,现有静态变量count=0,线程t1操作count自增5000次,线程t2操作count自减5000次,则count最后应该为0。

java
public class MultiThreadTest04 {

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 =
                new Thread("t1") {
                    @Override
                    public void run() {
                        for (int i = 0; i < 5000; i++) {
                            count ++;
                        }
                    }
                };


        Thread t2 = new Thread("t2"){
            @Override
            public void run() {
                for (int i = 0;i<5000;i++){
                    count --;
                }
            }
        };

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

结果:

image-20200831132442921

count最终结果不为0,出现了线程安全问题。

3.2 成员变量

同静态成员变量相同,只有多线程读写同一对象的成员变量,才会引发线程安全问题。

java
public class MultiThreadTest05 {
    public static void main(String[] args) throws InterruptedException {
        Number number = new Number();
        Thread t1 = new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    number.add();
                }
            }
        };
        Thread t2 = new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    number.sub();
                }
            }
        };

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(number.getCount());
    }
}

class Number{
    private int count = 0;

    public void add(){
        count++;
    }

    public void sub(){
        count--;
    }

    public int getCount(){
        return count;
    }
}

结果为:

image-20200831133044551

3.3 局部变量

如果多线程共享同一个局部变量,则分情况讨论:

  • 如果局部变量是基本类型,如intdouble等,则是线程安全的;
  • 如果局部变量是引用类型:
    • 如果引用的对象没有逃离方法作用范围,则是线程安全的;
    • 如果引用的对象逃离了方法作用范围,则不是线程安全的。

例如,现有静态方法test1()

java
public static void test1(){
    int i = 10;
    i++;
}

现有两个线程,分别调用方法test1(),则线程内存模型如下,在两个线程中,分别存在test1栈帧,所有局部变量i分别存在于两个线程的栈帧中,互不干涉。

image-20200904215445082

如果是局部变量是引用类型,则需要看其他线程是否能访问到局部引用变量所指向的对象,如果能,则线程不安全,否则线程安全,即是否发生了逃逸。

4. 解决安全问题-synchronized

4.1 synchronized介绍

synchronized,即俗称的对象锁,它采用互斥的方式让同一时刻至多一个线程能持有对象锁,其他线程再想获取这个对象锁时就会被阻塞,这样就能保证拥有锁的线程可以安全地执行临界区代码,不用担心线程上下文切换所带来的线程安全性问题。

synchronized的语法规则如下:

java
synchronized(对象){
    临界区代码
}

要保证多个线程是准备持有同一个对象锁的。

如果synchronized加在成员方法上,则对象锁为该类的一个实例对象:

java
class Test{
    public synchronized void test(){
        ...
    }
}
//等价于
class Test{
    public void test(){
        synchronized(this){
            ...
        }
    }
}

如果synchronized加在静态方法上,则对象锁为该类的class对象:

java
class Test{
    public synchronized static void test(){
        ...
    }
}
//等价于
class Test{
    public void test(){
        synchronized(Test.class){
            ...
        }
    }
}

4.2 案例问题解决

使用synchronized解决之前的问题:

java
public class MultiThreadTest04 {

    private static int count = 0;
    private static final Object LOCK = new Object();   //锁对象

    public static void main(String[] args) throws InterruptedException {
        Thread t1 =
                new Thread("t1") {
                    @Override
                    public void run() {
                        for (int i = 0; i < 5000; i++) {
                            synchronized (LOCK) {     //加锁
                                count++;
                            }
                        }
                    }
                };

        Thread t2 =
                new Thread("t2") {
                    @Override
                    public void run() {
                        for (int i = 0; i < 5000; i++) {
                            synchronized (LOCK) {     //加锁
                                count--;
                            }
                        }
                    }
                };

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

最终的结果count始终为0。

4.3 synchronized使用案例

通过几个案例,来判断synchronized锁住的是哪个对象以及结果。

4.3.1 情形一

java
public class Synchronized8_1 {
    public static void main(String[] args) {
        Test test = new Test();
        new Thread(()->{test.a();}).start();   //线程一
        new Thread(()->{test.b();}).start();   //线程二
    }
}

class Test{
    public synchronized void a(){
        System.out.println("aaaa");
    }

    public synchronized void b(){
        System.out.println("bbbb");
    }
}

锁住的对象是实例test,打印结果为:

txt
aaaa
bbbb
-----------
bbbb
aaaa

由于是线程一先启动,所有第二种情况很少出现。

4.3.2 情形二

java
public class Synchronized8_2 {
    public static void main(String[] args) {
        Test test = new Test();
        new Thread(() -> {test.a();}).start();    //线程一
        new Thread(() -> {test.b();}).start();    //线程二
        new Thread(() -> {test.c();}).start();    //线程三
    }
}

class Test {
    public synchronized void a() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("aaaa");
    }

    public synchronized void b() {
        System.out.println("bbbb");
    }

    public void c() {
        System.out.println("cccc");
    }
}

只有线程一和线程二有互斥关系,其要竞争持有的锁对象为实例test,线程三所调用的方法并没有synchronized,所以线程三与另外两个线程没有互斥关系。

打印结果有三种:

txt
cccc
一秒后
aaaa
bbbb
--------------
cccc
bbbb
一秒后
aaaa
--------------
bbbb
cccc
一秒后
aaaa

因为线程一在打印aaaa之前,会先睡眠1秒钟,并且线程三并不需要获取锁就能执行,所以cccc始终会在aaaa之前打印,所以结果不可能出现aaaa先打印再打印cccc

4.3.3 情形三

java
public class Synchronized8_3 {
    public static void main(String[] args) {
        Test test1 = new Test();
        Test test2 = new Test();
        new Thread(() -> {test1.a();}).start();      //线程一
        new Thread(() -> {test2.b();}).start();      //线程二
    }
}

class Test {
    public synchronized void a() {
        System.out.println("aaaa");
    }

    public synchronized void b() {
        System.out.println("bbbb");
    }
}

线程一持有的锁对象是test1,线程二持有的锁对象是test2,所以两个线程没有互斥关系,打印结果:

txt
aaaa
bbbb
---------------
bbbb
aaaa

4.3.4 情形四

java
public class Synchronized8_3 {
    public static void main(String[] args) {
        Test test1 = new Test();
        new Thread(() -> {test1.a();}).start();    //线程一
        new Thread(() -> {test1.b();}).start();    //线程二
    }
}

class Test {
    public static synchronized void a() {          //静态方法
        System.out.println("aaaa");
    }

    public synchronized void b() {
        System.out.println("bbbb");
    }
}

线程一持有的锁对象是Test.class,线程二持有的锁对象是test1,两个线程并没有互斥关系,所以打印结果:

txt
aaaa
bbbb
--------------
bbbb
aaaa

4.3.5 情形五

java
public class Synchronized8_3 {
    public static void main(String[] args) {
        Test test1 = new Test();
        Test test2 = new Test();
        new Thread(() -> {test1.a();}).start();       //线程一
        new Thread(() -> {test2.b();}).start();       //线程二
    }
}

class Test {
    public static synchronized void a() {          //静态方法
        System.out.println("aaaa"); 
    }

    public static synchronized void b() {          //静态方法
        System.out.println("bbbb");
    }
}

线程一和线程二竞争的是同一个锁对象Test.class,所以两个线程是互斥关系,打印结果为取决于哪个线程先拿到对象锁:

txt
aaaa
bbbb
---------------
bbbb
aaaa

5. 练习题

5.1 卖票

现有一个售票窗口,有余票10000张,有2000人来买票,每个人买票数在1-5张不等,请用程序模拟。

java
public class TicketSale {

    public static void main(String[] args) throws InterruptedException {
        TicketWindow ticketWindow = new TicketWindow(10000);    //售票窗口,余票10000

        List<Thread> threads = new LinkedList<>();     //线程列表,模拟顾客
        List<Integer> counts = new Vector<>();          //存储每个顾客买的票数

        for (int i = 0; i < 2000; i++) {
            Thread thread = new Thread(){
                @Override
                public void run() {
                    //模拟买票
                    int amount = ticketWindow.sale(((new Random()).nextInt(5))+1);   
                    counts.add(amount);           // 存储该位顾客买的票数
                }
            };
            threads.add(thread);
            thread.start();
        }

        // 等待2000位顾客买票结束
        for (Thread thread : threads) {
            thread.join();
        }

        // 统计卖出去的票和剩余的票
        int count = 0;
        for (Integer i : counts) {
            count += i;
        }
        System.out.println("卖出去的票为:"+count);
        System.out.println("剩余的票为:"+ticketWindow.getTickets());
        System.out.println("卖出去的票和剩余的票总共为:"+(ticketWindow.getTickets()+count));
    }
}

class TicketWindow{
    private int tickets;           //余票数

    public TicketWindow(int tickets){
        this.tickets = tickets;
    }

    public int getTickets(){
        return tickets;
    }

    // 售票,返回成功的售票数
    public int sale(int amount){
        if (tickets >= amount){
            tickets -= amount;
            return amount;
        }else {
            return 0;
        }
    }
}

多运行几次,结果为:

image-20200905120619840

image-20200905120634135

我们发现卖出去的票和剩余的票之和超过了原定的余票数(超卖问题),所以该方法存在线程安全问题。

我们需要将买票的过程加锁,由于只有一个售票窗口,所以在方法sale()上加关键字synchronized即可:

java
public synchronized int sale(int amount){
    if (tickets >= amount){
        tickets -= amount;
        return amount;
    }else {
        return 0;
    }
}

为什么List<Integer> counts = new Vector<>();不改用List<Integer> counts = new LinkedList<>();呢?

查看Vector类的add()方法源码:

java
public synchronized boolean add(E e) {
    modCount++;
    ensureCapacityHelper(elementCount + 1);
    elementData[elementCount++] = e;
    return true;
}

查看LinkedList类的add()方法源码:

java
public boolean add(E e) {
    linkLast(e);
    return true;
}
void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}

所以Vector是线程安全的,LinkedList是线程不安全的,所以也不需要将counts.add(amount);上锁。

5.2 转账

现有两个银行账户accountAaccountB,余额均为2000元,两个账户分别向另一个账户转账2000次,每次转账的金额在1-100元不等,请用程序模拟:

java
public class BankAccount {
    public static void main(String[] args) throws InterruptedException {
        Account accountA = new Account(2000); // 账户A
        Account accountB = new Account(2000); // 账户B

        Thread thread1 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 2000; i++) {
                    // 账户A向账户B转账
                    accountA.transfer(accountB, ((new Random()).nextInt(100)) + 1);
                }
            }
        };
        Thread thread2 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 2000; i++) {
                    // 账户B向账户A转账
                    accountB.transfer(accountA, ((new Random()).nextInt(100)) + 1);
                }
            }
        };

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("账户A和账户B余额之和为:"+(accountA.getAccount()+accountB.getAccount()));
    }
}

class Account {
    private int account; // 账户余额

    public Account(int account) {
        this.account = account;
    }

    public int getAccount() {
        return account;
    }

    public void setAccount(int account) {
        this.account = account;
    }

    // 转账
    public void transfer(Account target, int amount) {
        if (this.account >= amount) {
            this.setAccount(this.getAccount() - amount);
            target.setAccount(target.getAccount() + amount);
        }
    }
}

运行结果:

image-20200905140136088

image-20200905140148551

image-20200905140159039

发现两个账户余额之和不是4000,导致转账后余额变少,说明出现了线程安全问题。

我们需要将转账过程置为临界区,那锁对象应该如何设呢?

java
public synchronized void transfer(Account target, int amount) {
    if (this.account >= amount) {
        this.setAccount(this.getAccount() - amount);
        target.setAccount(target.getAccount() + amount);
    }
}

这样设置可行吗?不可以,如果这样设置,则线程一持有锁对象为accountA,线程二持有锁对象为accountB,两个线程不是互斥关系。

应该让两个线程竞争同一个对象锁,所以可以考虑如下设置:

java
public void  transfer(Account target, int amount) {
    synchronized (Account.class) {
        if (this.account >= amount) {
            this.setAccount(this.getAccount() - amount);
            target.setAccount(target.getAccount() + amount);
        }
    }
}

运行结果:

image-20200905140646271