目录
线程安全问题的原因
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线 程安全的。
1 .操作系统随机调度/抢占式执行
抢占式执行(preemptive execution)指的是操作系统在运行多个进程或线程时,会在一定的时间片内,按照一定的调度算法来轮流执行各个进程或线程。如果一个进程或线程正在执行时,出现了一些特殊情况(如I/O请求、中断请求等),操作系统就会暂时中止当前进程或线程的执行,切换到其他进程或线程的执行,以便处理这些请求。这种被中止的进程或线程在某个时刻后,仍然会被操作系统调度执行。
2 .多个线程操作同一个变量
当修改变量这个操作并非原子性的。这样在并发的环境下就很容易出现线程安全问题。
3 .原子性
当我们使用++这种操作时,涉及三条机器指令(取出 运算 放回),当指令不是单一机器指令时 , 这时我们就可以将其称为并不具有“原子性”。若不具有原子性,我们的操作可能就会被操作系统给打乱了顺序执行。
4 .内存可见性
当我们的一个线程修改,另一个线程读取时操作系统会自动给我们进行优化,这个过程就很容易造成内存可见性问题。当我们一个操作重复多次结果一样的时候 ,操作系统会自动给我们进行优化 ,然后不再进行这个操作 ,导致接下来的操作读取时都是判断最开始读时候的数据
5 .指令重排序
指令重排序是因为编译器对我们的代码进行了一些“自作主张”的优化,编译器会在保持逻辑不变的情况下。调整代码的顺序,从而加快代码的执行效率。这样也会出现线程安全问题。
解决方案
1 . 加锁
加锁就可以将不是原子的操作转换成原子的。在这里使用synchronized来进行加锁
这个例子是创建了两个线程,这两个线程想对同一个对象来进行++操作,每个线程共操作2500次,一共5000次。按常理说在线程执行完之后,我们的预期是5000。但是这里我们发现,输出的结果不是5000,并且每次运行的结果都是不同的!
class Counter{
public int counter;
public void add(){
counter++;
}
public int getCounter() {
return counter;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter c = new Counter();
Thread t1 = new Thread(() ->{
for (int i = 0; i <2500 ; i++) {
c.add();
}
});
Thread t2 = new Thread(() ->{
for (int i = 0; i <2500 ; i++) {
c.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(c.getCounter());
}
}
这就是多个线程修改一个变量而导致的线程安全问题。是因为++这个操作并不是原子性的,它分为1. 从内存把数据读到 CPU 2. 进行数据更新 3. 把数据写回到 CPU 三个操作。而我们使用了synchronized之后就不同了:我们将add方法加上synchronized,此时答案就是我们预期的结果5000。加了synchronized之后,进入了方法就会加锁,除了方法就会解锁。如果两个线程同时尝试加锁,此时只有一个线程可以获取锁成功,而另一个线程就会阻塞等待。阻塞到另一个线程释放锁之后,当前线程才能获取锁成功。 加锁的本质就是把并发变成了串行.
class Counter{
public int counter;
public void add(){
synchronized (this){
counter++;
}
}
public int getCounter() {
return counter;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter c = new Counter();
Thread t1 = new Thread(() ->{
for (int i = 0; i <2500 ; i++) {
c.add();
}
});
Thread t2 = new Thread(() ->{
for (int i = 0; i <2500 ; i++) {
c.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(c.getCounter());
}
}
synchronized的特性
1.互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁2.刷新内存~synchronized 的工作过程
1. 获得互斥锁
2. 从主内存拷贝变量的最新副本到工作的内存
3. 执行代码
4. 将更改后的共享变量的值刷新到主内存
5. 释放互斥锁3.可重入
可重入直观上来讲,就是同一个线程针对同一个锁,连续加锁了两次,如果出现了死锁,就是不可重入,如果不会死锁,就是可重入的~~
死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止
死锁的四个必要条件:
1)互斥使用~ 一个锁被一个线程占用了之后,其他线程占用不了(锁的本质,保证原子性)
2)不可抢占~ 一个锁被一个线程占用了之后,其他线程不能把这个锁给抢走
3)请求与保持~ 当一个线程占据了多把锁之后,除非显式的释放锁,否则这些锁是始终都是被该线程所持有的
4)环路等待,等待关系(为避免环路等待,只需要约定好,针对多把锁加锁的时候,有固定的顺序即可)
2 .volatile
volatile保证内存可见性,禁止编译器优化
volatile只是处理一个线程读,一个线程写的情况
volatile不保证原子性,也不会引起线程阻塞
这个例子是创建了两个线程,一个线程不断地读一个变量,而一个线程修改一个变量。我们的预期是,当t2修改了flog的值之后,使flog不再为0,此时跳出循环,线程t1结束。当我们的t2修改了flog的值之后,我们发现t1线程并没有结束,程序仍然在运行.
public class Main {
public static int flog = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() ->{
while (flog == 0){
}
System.out.println("t1结束");
});
Thread t2 = new Thread(() ->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个数字:");
flog = scanner.nextInt();
});
t1.start();
t2.start();
}
}
上面这个代码可见 我们输入了1后 循环并没有停止 我们的比较操作是在一个while循环中进行的,它的执行速度极快。而循环比较了这么多次,在t2修改flog之前,flog的值和内存读取到的结果是一样的。循环了很多次 此时我们的编译器便做出了优化 ,不再重复读取内存上的值 ,只读取一次放在CPU中 , 这样过后我们的t2即便改变了flog的值 , 但是由于t1不在读取内存上的值 , 所以我们的循环没有停下来, 此时,volatile就能发挥作用了。将flag变量加上volatile关键字,告诉编译器,这个变量是“易变”的,不用进行编译器优化。
public class Main {
volatile public static int flog = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() ->{
while (flog == 0){
}
System.out.println("t1结束");
});
Thread t2 = new Thread(() ->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个数字:");
flog = scanner.nextInt();
});
t1.start();
t2.start();
}
}