1.应用场景
由于同一进程的多个线程共享同一块存储空间,多个线程访问同一个对象并且某些线程还想修改这个对象会带来安全问题,为了保证数据在方法中被访问时的正确性就需要进行线程同步。
线程同步其实就是一种等待机制,多个需要同时访问此对象的线程进入这个对象的等待池形成队列,等待前面线程使用完毕,下一个线程再使用。
2.多线程同步实现原理
通过private关键字来保证数据对象只能被方法访问,针对方法运用锁机制synchronized,当一个线程获得对象的排它锁,独占资源﹐其他线程必须等待,使用后释放锁即可。
3.锁机制synchronized存在的问题
- 一个线程持有锁会导致其他所有需要此锁的线程挂起;
- 在多线程竞争下,加锁,释放锁会导致比较多的上下文切换和调度延时,引起性能问题;
- 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能问题。
4.如何保证线程安全
思路:
- 使用没有共享资源的模型
- 适用共享资源只读,不写的模型
2.1 不需要写共享资源的模型
2.2 使用不可变对象
- 直面线程安全(重点)
3.1 保证原子性
3.2 保证顺序性
3.3 保证可见性
4.1 尽量使用局部变量代替“实例变量和静态变量”。
4.2 创建多个对象
如果必须是实例变量,那么可以考虑创建多个对象,这样实例变量的内存就不共享了。(一个线程对应1个对象,100个线程对应100个对象,对象不共享,就没有数据安全问题了。)
4.3 使用synchronized线程同步机制。
4.4 借助Lock加锁
不一定要在同一个方法中进行解锁,如果在当前的方法体内部没有满足解锁需求时,可以将lock引用传递到下一个方法中,当满足解锁需求时进行解锁操作,方法比较灵活。
private Lock lock = new ReentrantLock();//定义Lock类型的锁
public void withdraw(double money){
// t1和t2并发这个方法。。。。(t1和t2是两个栈。两个栈操作堆中同一个对象。)
// 取款之前的余额
lock.lock();//上锁
double before = this.getBalance(); // 10000
// 取款之后的余额
double after = before - money;
// 在这里模拟一下网络延迟,100%会出现问题
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 更新余额
// 思考:t1执行到这里了,但还没有来得及执行这行代码,t2线程进来withdraw方法>了。此时一定出问题。
this.setBalance(after);
lock.unlock();//解锁
}
4.5 借助volatile关键字
4.5.1 内存可见性问题
import java.util.Scanner;
class MyCounter {
public int flag = 0;
}
public class ThreadDemo8 {
public static void main(String[] args) {
MyCounter myCounter= new MyCounter();
// t1要循环快速重复读取
Thread t1 = new Thread(()->{
while (myCounter.flag==0) {
}
System.out.println("t1循环结束");
});
// t2 进行修改
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
myCounter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)
从JMM(Java Memory Modle)角度重新表述这个内存可见性问题:
Java程序里,每个线程分别有自己的工作内存(t1和t2的工作内存不是同一个东西)。
t1线程进行读取的时候,只是读取了工作内存的值。
t2线程进行修改的时候,先修改工作内存的值,然后再把工作内存的内容同步到主内存中。
由于编译器优化,导致t1没有重新从主内存同步数据到工作内存,当 t2 对 flag 变量进行修改, 此时 t1 感知不到 flag 的变化。
读到的结果就是修改之前的结果。(工作内存是指CPU寄存器和CPU的cache)
// 当输入 1 的时候,t1 这个线程并没有结束循环。
while(myCounter.flag == 0) {
}
这里的操作分为两步:
- load,把内存中的flag的值,读取到寄存器里。
- cmp,把寄存器的值和0进行比较,根据比较的结果,决定下一步往哪个地方执行。
这两步操作是个循环,速度极快。
在 t2 真正修改之前,load得到的结果都是一样的。
CPU对寄存器的操作比对内存的操作快很多,所以load操作和cmp操作相比,速度慢非常多。
由于load执行的速度太慢,而且反复load到的结果都一样,
JVM就自动优化,进行指令重排序:不在真正的重复load,只读取一次就好了,当t2对flag变量进行修改, 此时t1感知不到flag 的变化,所以就造成了内存可见性问题。
4.5.2 volatile保证内存可见性原理
volatile的两个功能:解决内存可见性问题,禁止指令重排序。
代码在写入 volatile 修饰的变量的时候,先改变线程工作内存中volatile变量副本的值,再将改变后的副本的值从工作内存刷新到主内存。
代码在读取 volatile 修饰的变量的时候,先从主内存中读取volatile变量的最新值到线程的工作内存中,再从工作内存中读取volatile变量的副本。
给变量加上volatile关键字,告诉编译器这个变量时“易变的”,每一次都一定要重新读取这个变量的内容,不要进行激进的指令重排序优化了。
加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了。
案例:
// 如果给 flag 加上 volatile
static class Counter {
public volatile int flag = 0;
}
// 执行效果
// 当用户输入非0值时, t1 线程循环能够立即结束.
4.5.3 volatile 不保证原子性
案例:
// 给 increase 方法去掉 synchronized
// 给 count 加上 volatile 关键字.
static class Counter {
volatile public int count = 0;
void increase() {
count++;
}
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
尽管volatile关键字提供了变量的可见性保证,但它并不提供原子性保证。count++操作不是原子性的,因为它包含了三个独立的步骤:读取count的值,将其加一,然后将新值写回count。如果有两个线程同时执行这个操作,它们可能会读取相同的count值,各自加一,然后写回相同的结果,导致计数不正确。