多线程——线程安全问题

在我们使用多线程的时候,要避免出现线程不安全的情况发生,那是什么导致的线程不安全,以及为什么要避免发生线程不安全,又该如何避免线程不安全,我会在接下来的内容里一一介绍。

一、线程安全的概念

线程安全就是指我们在多线程运行环境下,代码运行结果能在我们的符合的预期中,那么就说明这个程序是一个线程安全的程序。

二、线程不安全的原因

1.修改共享数据

博主之前有提过,多线程是在我们的JVM虚拟机上进行调用的,那就会存在一些操作性的问题,如果我将一个变量放在堆里面,那它就是一个共享的数据,所有的线程都可以对它进行修改,那它就是一个共享的数据,就会导致线程不安全。

2.不具备原子性

试想有这样一种情况,你在手机上进行转账操作,刚在手机上转完帐,你就立马在银行去进行取钱操作,结果发现你银行卡里的钱并没有少,那这个就是不具备原子性的典型例子,在客户端A和客户端B对应的数据表并没有进行任何的保护,如果客户端A的数据修改后,客户端B的数据也同样进行了修改,这个就是具备了原子性。我们也把原子性称作同步互斥,表示不同的操作是互相排斥的,这样就不会发生线程不安全的问题了。

3.不具备可见性

可见性体现在JMM(Java内存模型)上,JMM一定程度上造成了线程不安全的情况发生。

在JDK1.2之前的版本,Java的内存模型实现是在主内存(共享内存)中读取变量,而现在的Java内存模型下,线程是可以把变量存储到本地内存当中,不直接从主内存中进行读写。,这就会出现一种情况,那就是当一份数据在两个线程当中读取,线程一中的数据被篡改,返回给主内存当中,但是线程二可能还是用的本地内存当中储存的数据,也就是说当一个线程修改了共享变量,那么其他线程也应该立即读取到更新后的共享变量,如果没有就会导致线程不安全的情况发生。

注意,本地内存是JMM抽象的一个概念,他并不真实存在,其实是指 CPU 的寄存器和高速缓存。

4.具备有序性

见字知义,其实就是说我们给CPU的指令是一个有序的指令,我们可以将给CPU的指令进行重排序,可以优化CPU运行效率,比如妈妈给你一个任务,让你去买菜和啤酒带到姥姥家去,剩下的钱可以去小卖部买小零食,但问题是小卖部在你们家楼下,而菜市场离你们家比较远,如果你知道菜和啤酒要花多少钱,那你可以直接先在小卖部买你想要的零食,再顺路去买菜。但我们在多线程的运行环境下,指令可能会在不同的线程中进行操作,如果改变了指令的顺序,就可能会出现执行结果和预期有差距,会导致发生线程安全问题。

有关指令重排序,我们在这不作过多的讨论,这涉及到CPU 以及编译器的一些底层工作原理,和我们今天的内容关系并不是很密切,就不展开讨论了。

我们在上面已经谈及了四个可能会导致线程安全问题的情况。

三、如何避免线程不安全

示例一:

public class UnsafeThread {
    // 创建一个计数器类
    static class Counter {
        private int count = 0;
        void increase() {
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        // 创建一个计数器实例
        Counter counter = new Counter();
        // 创建两个线程
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.increase();
            }
        });

        // 启动两个线程
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("The final count is: " + counter.count);
    }

}

我们会发现,在线程不安全的情况下,每次运行的情况是不相同的,就是出现了线程安全的问题。

那我们该如何避免上述代码发生线程安全问题呢?

我们先来思考一下为什么代码会发生线程安全问题:

结合我们上面总结的一些导致线程不安全的情况,我们可以发现,我们的increase方法不能保证我们的程序是一个原子性的操作,在我们线程一的读取count的值的时候有,可能线程二也读取到了count的值,然后同时返回count++,如果两个线程同时读取到count的值为6,那么线程一和线程二都会在主内存将count修改成7,而不是正确的8。

现在我们明白了问题在哪,那我们就对症下药,去让我们的程序具有原子性:

我们可以使用synchronized关键字去将我们的increase方法加锁,这样线程一使用的时候,线程二就只能老老实实地进行等待,线程二也如此。有关synchronized关键字的相关知识,可以查看我另一篇博客,有比较详细的补充说明:多线程——synchronized关键字

我们将线程一和线程二的计数总数调到20万,看看运行结果和效率如何

public class UnsafeThread {
    // 创建一个计数器类
    static class Counter {
        private int count = 0;
        // 线程不安全的计数方法
        void increase() {
            count++;
        }
        // 线程安全的计数方法
        synchronized void increaseSafe() {
            count++;
        }

    }
    public static void main(String[] args) throws InterruptedException {
        // 创建一个计数器实例
        Counter counter = new Counter();
        // 创建两个线程
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                counter.increaseSafe();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                counter.increaseSafe();
            }
        });

        // 启动两个线程
        long beforeTime = System.currentTimeMillis();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("total running time:" + (System.currentTimeMillis() - beforeTime));
        System.out.println("The final count is: " + counter.count);
    }

}

public class UnsafeThread {
    // 创建一个计数器类
    static class Counter {
        private int count = 0;
        // 线程不安全的计数方法
        void increase() {
            count++;
        }
        // 线程安全的计数方法
        synchronized void increaseSafe() {
            count++;
        }

    }
    public static void main(String[] args) throws InterruptedException {
        // 创建一个计数器实例
        Counter counter = new Counter();
        // 创建两个线程
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                counter.increase();
            }
        });

        // 启动两个线程
        long beforeTime = System.currentTimeMillis();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("total running time:" + (System.currentTimeMillis() - beforeTime));
        System.out.println("The final count is: " + counter.count);
    }

}

我们发现加上synchronized关键字的程序,效率是要比不加synchronized关键字的效率是要低的,这是一件很正常的事,因为我们的increaseSafe方法上锁了,会出现锁竞争的情况,那么就会有线程需要等待这把锁被打开,就会加大时间成本的开销。

示例二:

public class UnsafeThread2 {
    // 定义一个银行用户对象
    static class User {
        private int id;
        private String name;
        private int money;

        public User(String name, int money) {
            this.name = name;
            this.money = money;
        }
        // 获取用户的余额
        public int getMoney() {
            return money;
        }
        // 减少用户的余额
        public void reduceMoney(int amount) {
            synchronized (this) {
                money -= amount;
            }
        }
        // 增加用户的余额
        public void addMoney(int amount) {
            synchronized (this) {
                money += amount;
            }
        }
    }


    // 创建两个线程,分别操作同一个银行用户对象
    public static void main(String[] args) {
        User user = new User("comerun", 1000);
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    user.reduceMoney(1);
                }
                System.out.println("comerun: " + user.getMoney());
            };
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    user.addMoney(1);
                }
                System.out.println("comerun: " + user.getMoney());
            };
        });

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

我们创建一个银行用户,再创建两个线程,用来模拟银行转账,我们来看一下结果如何:

我们会发现我们的银行账户多了或者少了几块钱,那可太要命了,本来就1000块钱的余额变得雪上加霜。我们可以在将我们创建的对象上面加锁,避免线程安全问题。

public class UnsafeThread2 {
    // 定义一个银行用户对象
    static class User {
        private int id;
        private String name;
        private int money;

        public User(String name, int money) {
            this.name = name;
            this.money = money;
        }
        // 获取用户的余额
        public int getMoney() {
            return money;
        }
        // 减少用户的余额
        public void reduceMoney(int amount) {
            money -= amount;
        }
        // 增加用户的余额
        public void addMoney(int amount) {
            money += amount;
        }
        // 增加用户的余额
        public void addMoneySafe(int amount) {
            synchronized (this) {
                money += amount;
            }
        }
        // 减少用户的余额
        public void reduceMoneySafe(int amount) {
            synchronized (this) {
                money -= amount;
            }
        }

    }

    // 创建两个线程,分别操作同一个银行用户对象
    public static void main(String[] args) {
        User user = new User("comerun", 1000);
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    user.reduceMoneySafe(1);
                }
                System.out.println("comerun: " + user.getMoney());
            };
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    user.addMoneySafe(1);
                }
                System.out.println("comerun: " + user.getMoney());
            };
        });

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

像这样的方式就可以解决大部分在开发当中遇到的线程安全问题了,还有很多复杂的情况,放到我们之后再进行讨论。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

comerun

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值