概述
目录
1.线程安全
1.1 出现线程不安全
1.2 线程不安全的原因
1.3 解决线程不安全(加锁)
2.加锁使用synchronized关键字
2.1 修饰方法
2.2 修饰代码块
2.3 synchronized的特性
2.4 锁竞争
3. volatile关键字(保证内存可见性)
3.1 volatile能保证内存可见性
3.2 volatile不保证原子性
3.3 JMM(java内存模型)
4.wait和notify(协调多个线程的执行顺序)
4.1 wait和notify方法
4.2 notifyAll方法
4.3 wait和sleep对比
1.线程安全
1.1 出现线程不安全
两个线程,每个线程都针对counter进行5w次自增,预期结果10w
class Counter {
public int count = 0;
public void increase() {
count++;
}
}
public class Demo08 {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
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: " + counter.count);
}
}
多次运行程序后,发现每次结果都不相同,就是不为10w
进程count++操作,底层是三条指令在CPU上完成的
(1)把内存中的数据读取到CPU寄存器中 load
(2)把CPU的寄存器的值,进行+1 add
(3)把寄存器中的值,写回到内存中 save
由于当前是两个线程修改一个变量,并且每次修改是三个步骤(不是原子的),而且线程之间的调度顺序是不确定的,最终导致两个线程真正执行这些操作时,可能会有多种执行的排列顺序了
1.2 线程不安全的原因
(1)操作系统调度的随机性,抢占式执行(内核实现的,没办法)
多个线程的调度执行过程,可以视为是“全随机”的
(在写多线程代码时,需要考虑到,任意一种调度的情况下,都是能够运行出正确结果的)
(2)多个线程修改同一个变量
String是不可变对象(不能修改String对象的内容)
不可变对象,不是指final修饰,而是把set系列方法隐藏了(private)
这样的好处其中一个是“线程安全”
(3)修改操作不是原子的(解决线程安全最常见的方法)
比如前面count++操作,本质是三个CPU指令
load+add+save(CPU执行指令,都是以“一个指令”为单位进行执行,一个指令相当于CPU上“最小单位了”,不能说指令执行一半就把线程调度走)
但是像有些修改操作,比如int赋值,就是单个CPU指令,这个时候更安全一些
(4)内存可见性
内存可见性属于是JVM的代码优化引入的bug
编译器优化:因为程序猿写代码的能力高低不同,所以想让编译器那个把写代码等价转化成另一种执行逻辑,使逻辑不变,效率提高
虽然这样的优化,能够使效率提高,非常优秀,但在多线程代码下容易出现误判
而在单线程代码下,一般情况优化没问题
(5)指令重排序....
1.3 解决线程不安全(加锁)
加锁
比如前面例子,在count++之前先加锁,在count++后,再解锁(这两个操作之间,就是独占这个的,别的线程用不了,这个独占就是 互斥)在加锁和解锁之间,进行修改,这个时候别的线程想要修改,就修改不了(别的线程只能阻塞等待,阻塞等待的线程,BLOCKED状态)
2.加锁使用synchronized关键字
synchronized几种写法
(1)修饰普通方法,锁对象相当于this
(2)修饰代码块,锁对象在()指定
(3)修饰静态方法,锁对象相当于类对象(不是锁整个类)
2.1 修饰方法
使用synchrosized关键字,来修饰一个普通方法
当进入方法的时候,就会加锁,方法执行完毕就会解锁
class Counter {
public int count = 0;
public synchronized void increase() {
count++;
}
}
public class Demo04 {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
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: " + counter.count);
}
}
锁,具有独占的特性,如果当前锁没人来加,加锁操作就能成功
如果当前锁已经被加上了,加锁操作就会阻塞等待
这个操作相当于把“并发”变成了“串行”,又会减慢执行效率
加锁并不是说,CPU一次性全部执行完,中间也是有可能调度切换的
即使t1切换走了,t2仍然是BLOCKED状态
increase里面涉及到锁竞争,这里的代码是串行执行的,但是for循环在加锁的外面,两个for仍然是并发的,所以这个代码仍然要比两个循环串行执行要快,但是肯定比不加锁要慢
效率:(完全串行 < 加锁 < 完全并发)
如果把for写到加锁的代码中,此时就和完全串行一样了
加锁需要考虑锁哪段代码,锁的范围不一样,代码的执行效果会有很大的影响
加锁的代码越多,就说“锁的粒度越大/越粗”
加锁的代码越少,就说“锁的粒度越小/越细”
线程安全,不是加锁了就一定安全,而是通过加锁,让并发修改同一个变量,变成串行修改同一个变量,才安全的
不正确的加锁操作,不一定能够解决线程安全问题
比如,一个线程加锁,一个线程不加锁,就不涉及到锁竞争,也就不会阻塞等待,也不会将并发修改变成串行修改
2.2 修饰代码块
可以把要进行加锁的逻辑放到 synchronized 修饰的代码块之中,也能起到加锁的效果
在使用锁的时候,一定要明确,当前针对哪个对象进行加锁,这直接影响到了后面操作是否会触发阻塞
()中要填的就是针对哪个对象进行加锁(被用来加锁的对象,就叫“锁对象”)
任意对象都可以在synchronized里面作为锁对象,
所以我们写多线程代码时,不用关心这个锁对象是谁,是哪种形态
只需要注意,两个线程是否锁同一个对象,如果锁同一个对象就有“锁竞争”
如果锁不同对象,就没有锁竞争
(1) 针对当前对象加锁,谁调用了.increase方法,谁就是this
(2)不用counter本身,而是用counter内部持有的另外一个对象
针对locker对象进行加锁,locker是Counter的一个普通成员,每个Counter实例中,都有自己的locker实例
(3)可以使用外部类的实例
如果synchronized直接修饰方法,相当于锁对象就是this
大部分情况下,直接写this作为锁对象,一般是可以的
2.3 synchronized的特性
(1)互斥
synchronized里面的锁对象是this,这两线程就是针对counter对象进行加锁,两个线程在执行过程中就会出现互斥的情况
(2)可重入
不会产生死锁,这样的锁叫“可重入锁”
会产生死锁,这样的锁叫“不可重入锁”
可重入锁底层实现,是比较简单的
只要让锁里面记录好,是哪个线程持有的这把锁
当第二次加锁时,锁一看发现还是那个加了锁的线程,就直接通过了,不会阻塞等待
可重入锁实现要点:
(1)让锁里持有线程对象,记录是谁加了锁
(2)维护一个计数器,用来判断什么时候是真加锁,什么时候是真解锁,什么时候直接放行
一个线程针对一把锁,连续加锁两次,
第一次加锁,能够加锁成功
第二次加锁,就会加锁失败(锁已经被占用)
导致在第二次加锁这里阻塞等待,等到第一把锁被解锁,第二把锁才能加锁成功
(第一把锁解锁,要求执行完synchronized代码块,也就是要求第二把锁加锁成功才可以)
两次加锁,第一次加锁成功,第二次加锁看这个锁加锁了没,如果锁了就直接放行,但需要考虑的是直接放行后,要不要真解锁,如何来判断
方法是,引入一个计数器,每次加锁,计数器++,每次解锁计数器--,如果计数器为0,此时的加锁操作才能真加锁,同样计数器为0,此时的加锁操作才能真解锁
2.4 锁竞争
锁竞争核心是,无论锁对象,是什么形态,什么类型,只要两个线程争一个锁对象,就会产生锁竞争
锁竞争的目的,是保证线程安全
下面看几种情况,理解一下锁竞争
(1)此时的locker是一个静态成员(类属性),类属性是唯一的(一个进程中,类对象只有一个,类属性也只有一个)
虽然counter和counter2是两个实例,但是这两个里面的locker实际是同一个locker
也就会产生锁竞争
(2)第一个线程是针对locker对象进行加锁,第二个针对counter本身加锁
这两个线程针对不同对象加锁,不会产生锁竞争
(3)类对象,在JVM进程中只有一个,如果多个线程来针对类对象加锁,就会锁竞争
所以下面这两个对象都是针对,同一个Counter.class加锁
3. volatile关键字(保证内存可见性)
3.1 volatile能保证内存可见性
先看一下什么叫内存可见性问题
但是前面的修改,对于t1的读内存操作不会有影响。
因为t1已经被优化成不再循环读内存了(读一次就完了)
t2.把内存改了,t1没发现,这就是内存可见性问题,是由编译器优化出现的问题
(前面说过,编译器优化的前提是保证逻辑不变,让效率提高,但是在多线程情况下编译器就可能出现误判)
解决方案:为了解决编译器把不该优化的进行优化,就可以在代码中进行显示提醒编译器,这段代码不要进行优化,这也是volatile的作用
下面来看volatile的作用
volatile作用:可以使用这个关键字来修改一个变量
此时被修改的变量,编译器就强制不进行优化(不优化就可以,读取到内存了)
3.2 volatile不保证原子性
可以看到加了volatile的count,运行程序后,不是10W,说明加了volatile,针对两个线程这样的情况,只能保证“内存可见性”,而不保证“原子性”
public class Demo01 {
static class Counter {
volatile public int count = 0;
public void increase() {
count++;
}
}
public static void main(String[] args) throws InterruptedException {
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);
}
}
3.3 JMM(java内存模型)
说到volatile,大概就要联系到JMM了
JMM = Java Memory Model(java内存模型)
更专业术语进行描述
从JMM的角度来看volatile:
正常程序执行的过程中,会把主内存的数据,先加载到工作内存中,再进行计算处理
编译器优化可能会导致不是每次都真的读取主内存,而是直接取工作内存中的缓存数据(就可能导致内存可见性问题)
volatile起到的效果,就是保证每次读取内存都是真的从主内存重新读取
(需要注意这里的 工作内存 不是真的内存,主内存才是真的内存)
4.wait和notify(协调多个线程的执行顺序)
4.1 wait和notify方法
wait notufy 就是用来调配线程执行顺序的
wait操作本质上三步走
(1)释放当前锁,保证其他线程能够正常往下进行(前提是得加了锁。才能释放)
(2)进行等待通知(前提是先要释放锁)
(3)满足一定条件的时候(别的线程调用notify),被唤醒,然后尝试重新获取锁
notify是包含在synchronized里面的
线程1没有释放锁的话,线程2也就无法调用到notify(因为锁阻塞等待)
线程1调用wait,在wait里面就释放了锁,这个时候虽然线程1代码阻塞在synchronized里面
但是此时锁还是释放状态,线程2能拿到锁
要确定加锁的对象,和调用wait的对象是同一个对象,并且也要确定调用wait的对象和调用notify的对象,也是同一个对象
下面上代码,看一下上面图示wait和notify的执行顺序
public class Demo03 {
public static void main(String[] args) throws InterruptedException {
//准备一个对象,保证等待和通知是一个对象
Object object = new Object();
//第一个线程,进行 wait 操作
Thread t1 = new Thread(() -> {
while(true) {
synchronized (object) {
System.out.println("wait 之前");
try {
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
//这里写的代码,实在notify之后执行的
System.out.println("wait 之后");
}
}
});
t1.start();
Thread.sleep(500);
//第二个线程,进行notify
Thread t2 = new Thread(() -> {
while (true) {
synchronized (object) {
System.out.println("notify 之前");
//这里写的代码,是在wait唤醒之前执行的
object.notify();
System.out.println("notify 之后");
}
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t2.start();
}
}
4.2 notifyAll方法
多个线程都在wait
notify是随机唤醒一个(用的更多)
notifyAll是全部唤醒(即使全部唤醒了所有wait,这些wait又需要重新竞争锁,重新竞争锁的过程仍然是串行的)
4.3 wait和sleep对比
理论上wait和sleep是完全没有可比性的,唯一相同的是都可以让线程进入阻塞等待的不同点:(1)wait是Object类的成员本地方法,sleep是Thread类的静态本地方法
(2)wait必须在synchroized修饰的代码块或方法中和使用,而sleep方法可以在任何位置使用
(3)wait被调用后当前线程进入BLock状态并释放锁,需要通过notify或notifyAll进行唤醒也就是被动唤醒,sleep被调用后当前线程进入TIMED_WAIT状态,不涉及锁相关操作,能主动唤醒。
(4)sleep必须进行异常捕获,而wait,notify和notifyAll不需要异常捕获
sleep必须捕获异常,而wait,notify和notifyAll不需要捕获异常
最后
以上就是笑点低老虎为你收集整理的多线程之二(【线程安全】synchronized+volatile+wait+notify)1.线程安全2.加锁使用synchronized关键字3. volatile关键字(保证内存可见性)4.wait和notify(协调多个线程的执行顺序)的全部内容,希望文章能够帮你解决多线程之二(【线程安全】synchronized+volatile+wait+notify)1.线程安全2.加锁使用synchronized关键字3. volatile关键字(保证内存可见性)4.wait和notify(协调多个线程的执行顺序)所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复