目录
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不需要捕获异常
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/87332.html