前言:多线程编程已经广泛开始使用,其可以充分利用系统资源来提升效率,但是线程安全问题也随之出现,它直接影响了程序的正确性和稳定性,需要对其进行深入的理解与解决。
在正式开始讲解之前,先让我们看一下本文大致的讲解内容:
目录
1.线程不安全概念及其原因
在多线程编程中,线程安全是一个至关重要的概念,当多个线程同时访问和操作共享数据时,如果没有适当的同步机制,可能会导致程序出现意想不到的结果。
下面通过一个简单的代码示例来观察线程不安全现象:
// 此处定义一个int类型的变量
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
// 对count变量进行自增5w次
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
// 对count变量进行自增5w次
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
// 如果没有这俩join,肯定不行的。线程还没自增完,就开始打印了。很可能打印出来的count值小于预期
t1.join();
t2.join();
// 预期结果应该是10w
System.out.println("count: " + count);
}
在上述代码中,我们创建了两个线程 t1
和 t2
,它们都试图对共享变量 count
进行大量的自增操作,理论上,当两个线程都完成任务后,count
的值应该达到100000。然而,实际运行结果却常常小于这个预期值(读者可以复制代码在编译器中自行尝试一下),这便是典型的线程不安全现象。
——那么,为何会出现这种情况呢?原因有如下两个:
至此,我们通过上述的讲解,我们就大致的了解了到底什么是多线程中的线程不安全以及产生线程不安全的原因了。
2.原子性问题
在多线程中,除了上述我们讲解的当我们有多个线程同时对同一个数据进行操作从而引起的线程安全问题外,原子性问题也是可能引起线程不安全的原因,那么什么是原子性问题呢?
原子性的概念:
这里我们还是使用上述的两个线程各自增加count5w次的例子来进行讲解,这里再让我们看一下上述的代码:
// 定义一个共享的int类型变量count,并初始化为0
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 创建第一个线程t1,其任务是对count进行50000次自增操作
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
// 创建第二个线程t2,同样对count进行50000次自增操作
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
// 启动线程t1
t1.start();
// 启动线程t2
t2.start();
// 调用t1线程的join方法,确保t1线程执行完毕
t1.join();
// 调用t2线程的join方法,确保t2线程执行完毕
t2.join();
// 预期count的值应该是100000,但实际结果往往并非如此
System.out.println("count: " + count);
}
在上述代码中的 count++
操作,看似简单的自增指令,实际上并非原子操作,在Java语言中,count++
大致可分解为以下三个步骤:
假设 t1
和 t2
线程同时执行 count++
操作,就可能出现如下情形:t1
线程读取了 count
的初始值为0,然而在执行加1操作之前,线程调度器切换到了 t2
线程。t2
线程同样读取到 count
的值为0,随后进行加1操作并将结果1写回 count,
此时,count
的值变为1,接着,t1
线程恢复执行,它依旧使用之前读取到的0进行加1操作,得到结果1,并将其写回 count
。
如此一来,最终 count
的值仅增加了1,而非预期的2。这便是因为 count++
操作不具备原子性,在执行过程中被其他线程中断,从而导致了错误的结果。
——这就是所谓的原子性问题。
3.可见性问题
在了解完上述的两种造成多线程中的线程安全问题的原因之后,在让我们看一下另一种造成多线程线程安全的原因——内存可见性问题
——那么什么是内存可见性问题(可见性问题)呢?
可见性的概念:
当然,提到内存可见性问题就不得不提及Java内存模型,那么Java内存模型和内存可见性问题又有什么联系呢?
Java内存模型与可见性问题的关系:
这样我们就大致的了解了什么是Java内存模型,以及Java内存模型与可见性问题的关系了。
我相信读者在看到这里的时候脑子里只用一个想法,我勒个去,上边这都是什么和什么啊?根本看不懂啊!没关系,接下来让我们使用一个例子来帮助你更好的理解上述内存可见性问题。
案例代码:
static class Counter {
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (counter.flag == 0) {
// 线程t1在此处循环等待,直到flag的值变为非0
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
在这个例子中,t1
线程在一个循环里持续检查 counter.flag
的值是否为0,如果是,则持续循环等待;t2
线程等待用户输入一个整数,并将其赋值给 counter.flag
。按照预期,当用户输入非0的值时,t1
线程应当结束循环并打印 "循环结束!"。
然而,实际情况可能是,即便 t2
线程已经修改了 counter.flag
的值,t1
线程却并未立即察觉到这个变化,依旧在循环中持续等待。这是因为 t1
线程可能始终在使用自己工作内存中的 counter.flag
副本,而没有及时从主内存更新该副本,从而引发了可见性问题。
至此,我相信读者通过上述的案例讲解之后,就对内存可见性问题有了进一步理解了!!!
4.指令重排序问题
讲解完上述三种产生多线程问题的原因之后,还有没有其他的可能产生多线程线程安全的原因呢?还真有,其就是指令重排序问题。
指令重排序的概念:
这里我们也是使用一个案例来帮助读者来进一步理解指令重排序问题。
// 定义两个共享变量
private static boolean initialized = false;
private static int value;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
value = 42;
initialized = true;
});
Thread t2 = new Thread(() -> {
if (initialized) {
System.out.println("value: " + value);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
在这个例子中,t1
线程首先对 value
赋值为42,随后将 initialized
设置为 true
。t2
线程则检查 initialized
的值,如果为 true
,就打印 value
的值。由于指令重排序的存在,t1
线程中的指令可能会被重新排序。
例如,initialized = true
可能会在 value = 42
之前执行。这样一来,当 t2
线程检查 initialized
的值为 true
时,value
的值可能还未被正确赋值,从而导致打印出错误的结果(可能是0,而不是42)。
这样我们就了解了什么是指令重排序问题了。
5.线程不安全的解决方案
学习完上述可能产生线程安全的原因之后,接下来就让我们学习一下如何去在多线程编程中防止程序发生线程安全问题。
(1)synchronized关键字
在学习如何使用synchronized关键字之前,先让我们看一下synchronized关键字是什么:
这里我们使用一个例子来进行讲解:
public class Demo2 {
public static int number = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
synchronized (locker) {
number++;
}
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
synchronized (locker) {
number++;
}
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(number);
}
}
代码解析:
通过上述的案例,我相信读者就可以对synchronized关键字有一定的理解了!
当然synchronized关键字还可以修饰方法,当修饰普通方法时,锁对象为当前对象(this
);修饰静态方法时,锁对象为类对象(class
),例如:
public class SynchronizedMethodDemo {
private static int count = 0;
// 修饰普通方法,锁对象为this
public synchronized void increment() {
count++;
}
// 修饰静态方法,锁对象为类对象
public synchronized static void staticIncrement() {
count++;
}
}
需要特别注意的是,使用 synchronized
关键字会带来一定的性能开销,因为获取和释放锁的过程需要消耗时间。因此,在实际应用中,应尽可能缩小同步代码块的范围,仅在必要之处进行同步操作,以此提高程序的性能。
补充:synchronized关键字的可重入性:
这里我们先给出可重入性的简介:
例如,当一个线程调用一个 synchronized
方法时,若该方法内部又调用了另一个 synchronized
方法,此时该线程能够继续获取锁并执行内部的 synchronized
方法,而不会被自身阻塞。这是因为在可重入锁的内部机制中,包含了“线程持有者”和“计数器”两个重要信息,当某个线程加锁时,若发现锁已被自己占用,那么它仍然可以顺利获取锁,并使计数器自增。只有当计数器递减为0时,锁才会真正被释放,从而允许其他线程获取该锁。
可重入性的特点:
如果读者看了上述的文字解释之后还是不太理解,那么我们接下看使用一个例子来帮助你进一步理解synchronized的可重入性:
public class ReentrantExample {
synchronized void methodA() {
System.out.println("Method A is called");
methodB(); // 可以在这里调用同一个对象的另一个同步方法
}
synchronized void methodB() {
System.out.println("Method B is called");
}
public static void main(String[] args) {
ReentrantExample example = new ReentrantExample();
example.methodA(); // 调用 methodA
}
}
在上面的例子中,当 methodA
被调用时,线程获得了锁并执行 methodA
,然后可以安全地调用 methodB
,因为它已经持有了该对象的锁,这就是synchronized的可重入性。
(2)volatile关键字
在了解完了synchronized关键字之后,让我们了解一下volatile关键字,首先先让我们了解一下什么是volatile关键字:
这里我们使用一个例子来进行讲解:
public class VolatileDemo {
private volatile boolean flag = false;
public void setFlag(boolean flag) {
this.flag = flag;
}
public boolean isFlag() {
return flag;
}
}
在上述代码中,flag
变量被 volatile
修饰。当一个线程调用 setFlag
方法修改 flag
的值时,其他线程能够立即察觉到这个修改。
public class VolatileExample {
public static void main(String[] args) {
VolatileDemo volatileDemo = new VolatileDemo();
Thread t1 = new Thread(() -> {
while (!volatileDemo.isFlag()) {
// 线程t1在此处循环等待,直到flag的值变为true
}
System.out.println("t1线程检测到flag为true,结束循环");
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
volatileDemo.setFlag(true);
System.out.println("t2线程将flag设置为true");
});
t1.start();
t2.start();
}
}
在这个例子中,t1
线程在一个循环中不断检查 volatileDemo.flag
的值,如果为 false
,则继续循环等待;t2
线程在睡眠1秒后将 flag
设置为 true
。由于 flag
被 volatile
修饰,当 t2
线程修改 flag
的值后,t1
线程能够立即看到这个修改,从而结束循环。
这样我们就了解了volatile关键字了。
以上就是本篇文章的全部内容了~~~