线程安全的确切定义是比较复杂的,不过我们可以这样认为:当多线程环境下的代码运行的结果是符合我们预期的,即在单线程环境下应该得到的结果,则说这个程序是线程安全的,反之,则是线程不安全.
注意:判定一个代码是否线程安全,要具体问题具体分析,不是加了锁就一定安全~
如果一个线程修改一个变量;多线程读取同一个变量,是安全的,修改不同变量,都是安全的。因此可以通过调整代码结构来避免这种问题。
来看一段带有线程安全问题的代码
class Counter{
public int count;
public void add(){
count++;
}
}
public class ThreadDemo10 {
public static void main(String[] args) {
//1.定义Counter实例
Counter counter = new Counter();
//2.定义两个线程,分别对counter 调用5w次的add方法 预期结果count = 10 0000
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t1.start();
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t2.start();
//3.等待两个线程结束
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//打印最终的count值
//输出的结果是无法确定的
System.out.println("count = "+counter.count);
}
}
此时输出的结果是和预期结果不同,这个现象称为bug。产生这个结果最根本的原因就是抢占式执行,随机调度。同时这也是个典型的线程安全问题。
为什么出现这种情况?
count++ 的++操作本质是要分成 三步走:
1.把内存中的值,读取到cpu的寄存器中。(load)
2.把寄存器中的值进行+1操作。(add)
3.把得到的结果写回内存中。(save)
(注意:这里的load add save 是cpu指令)
此处这段代码 是两个线程针对同一个count++,也就是有两组load add save。如果线程调度顺序不同,执行的顺序也就不同,导致最终结果就不同。
此处画的调度顺序只有6种,实际有无数种情况:
比如我们拿最后一组执行顺序来说明,t2先执行了load 把count加载到cpu寄存器中,执行add 把count进行+1操作,此时t1执行load 把count加载到cpu寄存器中(此时count依旧为0,t1读取到的count值为0;因为t2还没有进行save把count=1写回内存中),t1执行add把count进行+1操作,t1执行save把count写回内存,此时内存中count值为1;最后t2执行save(此时count在寄存器中的值是1)把count=1写回内存中,最终两个线程对count进行两次自增操作,实际上count经过两次++操作 只增加了一次。
修改操作不是原子性也就意味着在执行任务时,没有做完,执行中途 被调度走了(原子:即不可拆分的基本单位,对于上面的列子,对于count++就不是原子的,可以拆分成load、add、save,像单独一个load或者add和save这就是不可再拆分的,是原子的)。如果操作是原子性的,就不会有线程不安全的问题了。
synchronized
把count的add方法加锁:意味着进入add方法就被自动加锁,出了方法就自动解锁。那么如果两个线程同时尝试加锁,A线程成功获取到锁,B线程就会一直阻塞等待(BLOCKED状态),一直等到A线程解锁,B才会成功获取到锁(才能执行代码)。
class Counter{
public int count;
//add方法进行加锁
synchronized public void add(){
count++;
}
}
1): 如果两个线程A,B线程针对同一个对象进行加锁操作,就会出现锁冲突(锁竞争),A线程获取到锁(讲究先到先得),B线程阻塞等待,等待A线程解锁,B才能获取成功.
2): 如果此时针对不同的对象加锁,就不会出现锁冲突,因为这两个线程获取的是不同的锁,就不会有阻塞等待了.
3): 如果两个线程一个加锁一个不加锁,也是没有锁冲突的.
a)修饰普通方法 加锁对象是this
b)修饰静态方法 加锁对象是类对象
进入代码块就加锁,出了代码块就解锁。括号里的对象可以任意指定其他对象。
public void add(){
synchronized(this){
count++;
}
}
synchronized
如果一个线程针对同一个对象,连续加锁两次,是否会有问题?没有问题则叫可重入,有问题则叫不可重入。
synchronized public void add(){
synchronized(this){
count++;
}
}
上述代码,一个线程当进入add方法时,就会加锁(这次能够加锁成功),随后进入代码块,再次尝试加锁,此时站在锁对象(this)来看,它认为自己已经加锁了,这里要不要再进行加锁?还是进行阻塞等待?如果进行加锁,就是可重入锁,如果进行阻塞等待,就是不可重入锁,如果此处进行阻塞等待,那么就成了死锁(没法解锁)。
死锁: 此处A线程第一次加锁成功,如果有其他线程获取锁就会进行阻塞等待,可是A线程在第二个锁处进行阻塞等待了,由于其他线程都阻塞在第一个锁外,A线程又无法把第一把锁进行解锁,其他线程也进不来,就成了死锁。(抽象了 = =简单讲就是A加锁了,又卡在另一把锁,另一把锁也没线程来解锁,就死锁了)
万恶之源,罪魁祸首!操作系统调度线程是具有随机性的,多线程环境下会出现抢占式执行,此时就会出现代码执行顺序的可能性从一种情况变成了无数种情况,所有就需要保证在这种随机的线程调度顺序下,保证结果都是正确的预期结果.
比如t1先执行,t2先wait(阻塞,主动放弃cpu),等t1执行差不多了,再通过notify把t2唤醒,让t2执行。
如果使用join,则必须要t1彻底执行完,t2才能执行,sleep必须指定一个固定休眠时间,但实际执行多久我们并不确定,而wait和notify可以随便指定执行到何等程度时把t2唤醒或者让t2阻塞,控制多线程的执行顺序。
方法 | 说明 |
---|---|
wait() / wait(long timeout) | 让当前线程进入等待状态,此时处在WAITING状态。wait()放不加任何参数,就是一直等待。 |
notify() | 唤醒在当前对象上等待的线程 |
notifyAll() | 唤醒所有等待的线程 |
注意: wait, notify, notifyAll 都是 Object 类的方法 。
public class ThreadDemo12 {
public static void main(String[] args) throws InterruptedException {
Object ob = new Object();
ob.wait();
}
}
wait会做三件事:
synchronized (ob) {
//此时这个线程是阻塞在wait这一行代码处,等待notify唤醒
//虽然是阻塞,带实际上是释放了锁,其他线程可以获取到ob对象这个锁
ob.wait();
}
进行阻塞等待。
收到通知后,重新尝试加锁,获取到锁后,继续往下执行。
public class ThreadDemo13 {
public static void main(String[] args) {
Object object = new Object();
Thread t1 = new Thread(()->{
//这个线程负责等待
System.out.println("t1:wait 之前");
synchronized (object){
try {
object.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1:wait 之后");
}
});
Thread t2 = new Thread(()->{
//这个线程负责通知唤醒
System.out.println("t2: 通知之前");
synchronized (object){
object.notify();
}
System.out.println("t2: 通知之后");
});
t1.start();
//为了保证t1先执行
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
t2.start();
}
}
比如一个线程读操作,一个线程改,就可能会出现读操作的结果不符合预期,也就是读到的值可能不是修改后的值。
解决方案:保证内存可见性
volatile关键字能保证内存的可见性,不保证原子性。
class Number{
int flag = 0;
}
public class ThreadDemo11 {
public static void main(String[] args) {
//创建number
Number number = new Number();
Thread t1 = new Thread(()->{
//循环读取flag值
while(number.flag == 0){
;
}
System.out.println("t1线程结束");
});
t1.start();
Thread t2 = new Thread(()->{
//写操作 修改flag值
Scanner scanner = new Scanner(System.in);
number.flag = scanner.nextInt();
});
t2.start();
}
}
上述代码读写同一个变量,当t2进行写操作,t1循环获取flag值。预期结果是:t2输入的是非0,则t1的循环结束。实际情况是:当输入1的时候,t1线程一直未结束。这种情况就属于内存可见性问题。
t1里的while循环的条件判断语句 用汇编层次来看,有两步操作1、load 把内存中的flag值读到寄存器中,2、cmp 把寄存器的值和0比较,决定下一步怎么走(条件跳转指令)。注意此处while循环体没有语句,也就意味着while循环执行速度极快,可能1s上万次,而执行load执行效率速度太慢,所以当执行这么多次每次load的值都是相同的,于是JVM就进行自动优化,导致只读取一次flag值,每次比较就不再读取flag值,直接用寄存器之前存储的值来和0比较。但实际上我们有其他线程随时对flag进行修改,这就导致JVM自动优化导致了错误。也就是前面说的一个线程读,一个线程改,当修改后,读的可能就不是修改后的值。要想解决这个问题,就需要我们手动对flag这个变量加上volatile关键字,加上之后编译器就不会进行优化,一定是每次都重新读取flag值。
class Number{
//加上volatile关键字
volatile int flag = 0;
}
此时给flag加上volatile关键字后,当t2输入非0时,t1的while循环就成功结束了。注意:编译器优化是由于t1循环里没有语句,循环速度极快才导致了优化,如果在循环里加个sleep控制循环速度,即使不加volatile,该代码执行结果也会正确(t1循环结束)。
编译器觉得你的代码不够效率,于是把你的代码在保证逻辑的不变的情况下,进行调整(调整代码执行顺序),以加快程序的执行效率。
比如你写的代码执行顺序是1、2、3,编译器给你优化成1、3、2。
由于编译器认为我们的代码不够效率,进行了指令重排序(优化),那么我们可以告诉编译不要优化,比如上面的volatile关键字。