在我们使用多线程的时候,要避免出现线程不安全的情况发生,那是什么导致的线程不安全,以及为什么要避免发生线程不安全,又该如何避免线程不安全,我会在接下来的内容里一一介绍。
一、线程安全的概念
线程安全就是指我们在多线程运行环境下,代码运行结果能在我们的符合的预期中,那么就说明这个程序是一个线程安全的程序。
二、线程不安全的原因
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();
}
}
像这样的方式就可以解决大部分在开发当中遇到的线程安全问题了,还有很多复杂的情况,放到我们之后再进行讨论。