认识线程不安全
分析以下代码的结果
class counter{
int count = 0;
void increase(){
count++;
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
counter counter =new counter();
Thread thread = new Thread(()->{
int i = 0;
while (i < 5000){
counter.increase();
i++;
}
});
Thread thread1 = new Thread(()->{
int i = 0;
while (i < 5000){
counter.increase();
i++;
}
});
thread.start();
thread1.start();
thread.join();
thread1.join();
System.out.println(counter.count);
}
}
结果到达预想是10000,并且每次的结果不是唯一,这个问题是因为线程不安全导致的.
线程安全的概念
想写出一个线程安全的确切定义是复杂的,但可以这样理解:
如果在多线程的运行环境执行的结果符合我们的预期,即在单线程环境执行的结果一样,就说明这个代码程序的安全的.
导致线程不安全的原因
1. 多个线程修改一个变量(修改共同数据)
刚刚上面提及的代码中,就涉及多个线程修改数据.两个线程都有对counter.count变量进行修改,
2.原子性
什么是原子性: 我们可以这样子了解,我们把一段代码想象为去餐厅点菜吃饭并且只有一台机器进行点餐,此时每个线程就人,每个线程进行点菜,A线程先开始点餐,如果在A线程在点餐的过程中, B线程也可以参与点餐,此时就会导致A线程的点餐结果不同, 这就说明点餐是不具有原子性的.
如果将点餐的流程封装为一个”动作”,即需要等前一个线程完成点餐后,后一个线程才能进行这个”点餐动作”,这样就保证了”点餐动作”这段代码的原子性了.
在Java语句中一条Java语句不一定的原子性的,也不一定是一条指令
比如i++; 列如(i=10) 这个指令其实是由三条指令组成的:
- 从内存把数据(10)读取到cup
- 进行对数据操作(加1)
- 把数据写回到内存中
原子性对多线程的重要性
当一个线程在对一个变量进行修改,此时另一个线程插入进来,就会干扰操作,导致结果错误.
3.可见性
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.
Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.
线程之间的共享变量存在主内存 (Main Memory).
每一个线程都有自己的 "工作内存" (Workingd Memory) .
当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
4. 代码重排序
什么是代码重排序
有一段代码是这样写的
把家里的垃圾倒掉
整理家务
下楼买饮料
在代码执行时,jvm,cup指令集会对代码进行优化,比如,2->1->3,减少下楼次数,这种就是指令重排序.
指令重排序导致多线程不安全的原因:
编译器对于指令重排序的前提是 "保持逻辑不发生变化". 这一点在单线程环境下比较容易判断, 但 是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代 码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价
解决线程不安全的问题
- 使用synchronized关键字
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到 同一个对象 synchronized 就会阻塞等待.
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁
我们可以通过一个简单易懂的方式认识”加锁”,”解锁”是什么.
首先有请 , 小美 , 小莉, 小帅 , 丧彪 , 小沸 , 双面龟.
小帅 , 丧彪 , 小沸 ,他们同时追求小莉, 由于小帅确实有点小帅,小莉就答应了小帅的追求,此时小帅就成功对小莉”加锁”, 此时丧彪 , 小沸想对小莉进行加锁,就无法加锁成功,只能阻塞等待(充当备胎), 但由于双面龟选择对小美进行加锁,由于加锁对象不同所以双面龟加锁成功,当小帅和小美闹矛盾分手后 ,进行了”解锁”,此时丧彪 , 小沸就可以解除阻塞等待重新对小莉上锁,即使是丧彪先于小沸追求小莉, 但是丧彪不一定就能获取到锁, 而是和小沸重新竞争, 并不遵守先来后到的规则.
可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
理解”把自己锁死”
一个线程没有释放锁,又对这个锁进行加锁操作,由于锁没有被释放所以第二次加锁失败,开始阻塞等待.然而锁又无法释放,导致形成死锁.
由于java中 synchronized 是可重入锁,因此没有上面的问题.
代码示例
在下面的代码中increase 和 increase2 两个方法都加了 synchronized, 此处的 synchronized 都是针对 this 当前 对象加锁的.
在调用 increase2 的时候, 先加了一次锁, 执行到 increase 的时候, 又加了一次锁. (上个锁还没释 放, 相当于连续加两次锁)
这个代码是完全没问题的. 因为 synchronized 是可重入锁.
class counter{
int count = 0;
synchronized void increase(){
count++;
}
synchronized void increase2(){
increase();
}
}
在可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息.
如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取 到锁, 并让计数器自增.
解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
6. synchronized使用方式
Synchronized主要根据对象进行一系列操作,需要搭配具体的对象进行使用,
1.直接修饰普通方法: 锁的是 SynchronizedDemo对象
- class SynchronizdeDemo{
public synchronized void methond1() {
}
}
} - 修饰静态方法:锁的是锁的 SynchronizedDemo类对象
- class SynchronizdeDemo{ public static synchronized void methond2(){ } }
两者对比
class SynchronizedDemo{
public synchronized void methond() throws InterruptedException {
while (true) {
System.out.println("Hello world");
Thread.sleep(1000);
System.out.println("Hello java");
}
}
public static synchronized void methond2() throws InterruptedException {
while (true) {
System.out.println("Hello world");
Thread.sleep(1000);
System.out.println("Hello java");
}
}
}
public class Thread1 {
public static void main(String[] args) {
SynchronizedDemo synchronizedDemo =new SynchronizedDemo();
SynchronizedDemo synchronizedDemo1 = new SynchronizedDemo();
Thread thread = new Thread(()->{
try {
synchronizedDemo.methond1();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
Thread thread2 = new Thread(()->{
try {
synchronizedDemo.methond1();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
thread.start();
thread2.start();
}
}
修饰普通方法:相同对象的methrond方法,打印顺序是有规律的,而相反使用不同对象,打印顺序无法预测,两者说明synchronized修饰普通方法锁的是 SynchronizedDemo对象
Thread thread2 = new Thread(()->{
try {
synchronizedDemo1.methond1();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
修饰静态方法: 由于锁的是SynchronizedDemo类对象,不管调用对象是否相同,输出顺序都一样
Thread thread = new Thread(()->{
try {
synchronizedDemo.methond2();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
Thread thread2 = new Thread(()->{
try {
synchronizedDemo1.methond2();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
我们重点要理解: 两个线程竞争同一把锁, 才会产生阻塞等待, 两个线程分别尝试获取两 把不同的锁, 不会产生竞争
Java 标准库中的线程安全类
Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据,
又没有任何加锁措施.
1. ArrayList
2. LinkedList
3. HashMap
4. TreeMap
5. HashSet
6. TreeSet
7. StringBuilder
但是还有一些是线程安全的. 使用了一些锁机制来控制.
- Vector
- HashTable
- Concurrent
- HashMap
- StringBuffer
valatile关键字
Volatile能保证内存可见性
Volatile用于修饰变量,保证内存可见性
判断下面代码结果
class SynchronizedDemo {
volatile int flag = 0;
}
public class Thread1 {
public static void main(String[] args) {
SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
Thread thread1 = new Thread(()->{
while (synchronizedDemo.flag == 0){
//什么也不做
}
System.out.println("线程执行结束");
});
Thread thread2 = new Thread(()->{
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数");
synchronizedDemo.flag = scanner.nextInt();
});
thread1.start();
thread2.start();
}
}
当随便输入一个整数时,可以发现thread1线程仍没停止,说明while循环的判断条件仍为true,但flge的值确实已被我们修改,其实上述原因是内存可见性在搞鬼.
我们知道CPU的处理速度的非常快的,在短短的3秒时间 , flag==0 这条语句可能已经执行了上百亿次了 , 由于结果每次flag的值都为零,所以就暂时停止了从主内存读取flag到自己的工作内存中.因此当thread2对 flag 变量进行修改, 此时thread1感知不到 flag 的变化
使用volatile修饰变量
代码在写入 volatile 修饰的变量的时候,
改变线程工作内存中volatile变量副本的值
将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候
从主内存中读取volatile变量的最新值到线程的工作内存中
从工作内存中读取volatile变量的副本
使用synthronized也可以保证内存可见性
在判断代码处加上
while (true){
synchronized(synchronizedDemo){
If(counter.flag != 0){
break;
}
}
}
但需要注意:volatile不保证原子性,synchronized能同时够保证原子性和内存可见性
好了本次的学习分享就到这里了,如果本次分享对你有帮助的话,请点一个免费的赞支持一下作者哦.谢谢啦! 我们下次再见.