线程安全问题的万恶之源就是多线程的抢占式执行所带来的随机性.
有了多线程, 此时抢占式执行下, 代码执行的顺序, 会出现更多的变数, 代码执行顺序的可能性就从一种情况变成了无数种情况. 只要有一种情况使得代码结果不正确, 都是视为bug, 线程不安全.
有线程安全的代码
以下是一个有线程安全的的代码:
java">class Counter{public int count;public void add(){count++;}
}public static void main(String[] args) throws InterruptedException {Counter counter = new Counter();//搞两个线程, 两个线程分别针对counter 来调用5w次的add方法Thread t1 = new Thread(() -> {for(int i = 0;i < 50000;i++){counter.add();}});Thread t2 = new Thread(() -> {for(int i = 0;i < 50000;i++){counter.add();}});//启动线程t1.start();t2.start();//等待两个线程结束try{t1.join();t2.join();} catch (InterruptedException e){e.printStackTrace();}//打印最终的 count 值System.out.println("count = "+counter.count);}
代码结果:
从代码结果可以知道, 并不是和预期一样, 达到10w次.
为什么会出现这种情况呢?
原因就出现在 "count++" 这里
"++" 操作本质上要分成三步(类似于汇编)
第一步: 先把内存中的值, 读取到CPU的寄存器中 (load)
第二部: 把CPU寄存器里的数值进行 "+1" 运算 (add)
第三步: 把得到的结果写回到内存中 (save)
如果是两个线程并发执行, 此时就相当于两组load add save 进行执行, 此时不同的线程调度顺序就可能会产生一些结果上的差异.(将相当于6个操作, 可能出现的排列次序不同)
这是由于线程之间是随机调度的, 导致此处的调度顺序充满着其他的可能性
脏读
由于两个核心上处理同一个内存上的变量, 如果是按顺序完成自身的任务(load add save)后, 另一个线程在开始自增, 那么结果肯定是10w次, 但是由于随机调度, 就会出现一个线程自增后, 还没有保存, 而另一个线程已经读取的情况, 就会导致内存上只++1次, 没有按预期一样++2次.
线程安全问题的主要原因
线程安全问题的主要原因:
1. [根本原因] 抢占式执行, 随即调度,
2. 代码结构: 多个线程同时修改同一个变量
一个线程修改一个变量不会出现安全问题
多个线程读取同一个变量不会出现安全问题(例如:String是不可变对象, 天然是线程安全的)
多个线程修改多个不同的变量不会出现安全问题
3.原子性: 如果修改操作是非原子的, 就会容易出现线程安全问题
原子性: 比如汇编中的一条指令,或者说几条指令互不影响
针对线程安全问题的解决, 最主要的手段就是从原子性入手, 把非原子的操作, 变为原子的
4. 内存可见性: 如果一个线程读, 一个线程修改 也会出现安全问题
5. 指令重排序: 编译器主动调整你的代码
线程安全问题的解决----Synchronized
java">//synchronize 是一个关键字, 表示加锁
synchronized public void add(){count++;
}
加了synchronized之后, 进入方法就会加锁, 出了方法就会解锁, 如果两个线程同时尝试加锁, 此时一个能获取锁成果, 另一个只能阻塞等待(BLOCKED) 一直阻塞到线程释放锁(解锁), 当前线程才能加锁成功.
加锁, 就是保证原子性, 加锁的本质是把并行, 变成了串行
加锁之后, 代码结果就变成了10w次
synchronized关键字----监视器monitor lock
synchronized 使用方法
1.修饰方法
1) 修饰普通方法; 锁对象就是this
java">public synchronized void add(){count++;
}
直接把synchronized 修饰到方法上了, 此时相当于对 this 加锁.
java">Thread t1 = new Thread(() -> {for(int i = 0;i < 50000;i++){counter.add();}
});Thread t2 = new Thread(() -> {for(int i = 0;i < 50000;i++){counter.add();}
});
t1 执行 add 就加上锁了, 针对count这个对象加上锁了, t2 执行 add 的时候, 也尝试对counter加锁, 但是由于counter 已经被 t1 给占用了, 因此这里的加锁操作就会阻塞.
2)修饰静态方法: 锁对象就是类对象(Counter,class)
与1) 同理
2.修饰代码块
显式/手动指定锁对象
java">public void add(){synchronized (this){count++;}
}
"( )" 内可以指定任意想指定的对象, 不一定非是 this, 进了代码块就加锁, 出了代码块就解锁
synchronized 的特性
1) 互斥
进入 synchronized 修饰的代码块, 相当于 加锁
推出synchronized 修饰的代码块, 相当于解锁
加锁
如果两个线程针对同一个对象进行加锁, 就会出现锁竞争/锁冲突, 一个线程能够获取到锁(先到先得)另一个线程阻塞等待, 等待到上一个线程解锁, 它才能获取锁成功
如果两个线程针对不同对象加锁, 此时不会发生锁竞争/锁冲突. 这俩线程都能获取到各自的锁, 不会阻塞等待了.
2) 可重入
synchronized 同步块对同一条线程来说是可重入的, 不会出现自己把自己锁死的问题.
Java标准库中的线程安全类
Java标准库中很多都是不是线程安全的. 例如以上, 这些类可能会涉及到多线程修改共享数据, 有没有任何加锁措施
还有一些是线程安全的, 使用了一些锁机制来控制
还有的虽然没加索, 但是不涉及"修改", 仍然是线程安全的-----String