文章目录
- synchronized和volatile关键字
- 一、加锁互斥
- 二、synchronized的使用
- 1.修饰实例方法
- 2.修饰类方法
- 三、可重入锁
- 1.死锁
- 关于死锁
- 哲学家就餐问题
- 如何避免死锁
- 死锁成因的四个必要条件
- 四、volatile关键字
- 1.保证内存可见性
- 什么是内存可见性问题
- 如何解决
- Java内存模型JMM
- volatile不能保证原子性,保證的是内存可见性
- 五、wait 和 notify
- 1.wait
- 2.notify
- ”线程饿死“
synchronized和volatile关键字
synchronized 监视器锁 monitor lock
一、加锁互斥
前文提到了线程安全问题,为了解决线程安全问题,最常用的方法就是使用synchronized关键字进行加锁处理。
synchronized在使用时,要搭配代码块{ },进入{ 就会加锁,出了 } 就会解锁。
-
在已经加锁的状态中,另一个线程尝试同样加这个锁。就会产生“锁冲突/锁竞争”。此时,后一个线程就会阻塞,一直等到前一个线程解锁为止。本质上把“并行”变成了“串行”。
-
synchronized ()中需要表示一个用来加锁的对象,这个对象是谁不重要。重要的是根据这个对象来区分两个进程是否在竞争同一个锁。
-
如果两个线程在针对同一个对象加锁,就会有锁竞争。如果不是针对同一个对象加锁,就没有锁竞争,仍然是并发执行。
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
//对变量自增50000次
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
//()中需要表示一个用来加锁的对象,这个对象是谁不重要
//主要是根据这个对象来区分两个进程是否在竞争同一个锁
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (locker){
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();//没有join的话,线程还没自增完,就会打印count
System.out.println("count: " + count);
}
count: 100000
- 引入锁之后,保证了线程安全
synchronized用的锁是存在Java对象头里面的。在对象头中,就有属性来表示当前这个对象是否加锁,来进行区分。
Java的一个对象对应的空间中,除了自己定义的一些属性外,还会有一些自带的属性,这些自带的属性就叫对象头。
二、synchronized的使用
synchronized除了修饰代码块外,还可以修饰一个实例方法,或者修饰一个类方法。
class Counter {
public int count;
public static int num;
synchronized public void increase() {
count++;
}
public void increase2() {
synchronized (this) {
count++;
}
}
synchronized public static void increase3() {
num++;
}
public static void increase4() {
synchronized (Counter.class) {
num++;
}
}
}
public class demo1 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase2();
Counter.increase3();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase2();
Counter.increase4();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count: " + counter.count);
System.out.println("num: " + Counter.num);
}
1.修饰实例方法
- 用synchronized来修饰一个方法,此时就是使用this作为锁对象
synchronized public void increase() {
count++;
}
public void increase2(){
synchronized (this){
count++;
}
}
两种写法等价
2.修饰类方法
- 如果用synchronized来修饰类方法,就是针对类对象加锁。
类对象:.java代码会编译成.class文件。.class文件会被JVM加载到内存中。加载到内存中的数据就是类对象。
类对象中包含类的属性、方法、继承、接口(名字、类型、权限)等信息。同时,类对象在一个Java进程中,是唯一的。
public static int num;
synchronized public static void increase3() {
num++;
}
public static void increase4() {
synchronized (Counter.class) {
num++;
}
}
两种写法等价
锁对象是谁不重要,重要的是两个线程中锁对象是否是同一个对象。同一个对象才能进行锁冲突。
三、可重入锁
可重入锁,指的是一个线程。连续针对一把锁加锁两次,不会出现死锁。
1.死锁
synchronized (locker){
synchronized (locker){
//
}
}
同一个对象加两次锁。第一次加锁,假设加锁成功,locker就属于“被锁定”状态。在进行第二次加锁的时候,原则上来说是要进行阻塞,等待到锁被释放之后才能进行第二次的加锁操作。此时由于第二次加锁被阻塞了,就出现了死锁。(线程卡死)第二次无法加锁导致第一个加锁无法执行完毕解锁。第一次无法解锁,第二次就不能加锁。
- 把synchronized设计成“可重入锁”就可以解决死锁的问题。
让锁记录一下,是哪一个线程给它锁住的。后续再次加锁的时候,如果加锁线程是之前持有锁的线程,那么直接加锁成功。
无论可重入锁有多少层,都需要在最外层才能释放锁。保证中间代码的线程安全。在锁对象中,不光要记录谁拿到了锁,还要记录这个锁被加了几次。每加锁1次,计数器+1,每解锁一次,计数器减一。出了最后一个大括号后减为0,此时进行锁的释放。
关于死锁
1.一个线程针对一把锁,连续加锁两次。如果是不可重入锁,就死锁了。
synchronized不会出现死锁,因为它是可重入锁。C++的 std::mutex是不可重入锁,就会出现死锁。
2.两个线程,两把锁(无论是不是可重入锁,都会死锁)
1.t1获取锁A,t2获取锁B
2.t1尝试获取B,t2尝试获取A
出现死锁,家钥匙锁车里,车钥匙锁家里
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);//确保两个线程都先获取到一把锁
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2){
System.out.println("t1加锁成功");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker1){
System.out.println("t2加锁成功");
}
}
});
t1.start();
t2.start();
}
- 两个线程都没有获取到第二把锁
- 两个synchronizid是嵌套关系,在占有一把锁的前提下,再获取另一把锁(可能出现死锁)
- 而并列关系是先释放当前的锁,再获取下一把锁(不会死锁)
两个线程都是BLOCKED状态,因为等待锁而出现了阻塞。
3.N个线程,M把锁
更容易出现死锁的情况
哲学家就餐问题
哲学家就餐问题就是经典的N个线程M把锁的模型。
1.在一个圆桌上,每个哲学家左右两侧放了一根筷子,桌子的中间是一碗面。
2.每个哲学家要么进行思考,放下筷子,什么都不干。要么拿起左右两根筷子,开始吃面
3.哲学家吃面和思考是随机的。
4.哲学家什么时候吃完也是随机的
5.哲学家吃面时,会拿起左右两侧的筷子,相邻的哲学家就会阻塞等待。
哲学家=线程 ,哲学家行为的随机=线程的随机调度 ,筷子=锁
通常条件下,可以正常运转,但是在极端情况下。所有哲学家都想吃面条,同时拿起了左手边的筷子。所有人都只有一根筷子,都只能阻塞等待。
要解决问题,需要给筷子进行编号。同时规定每个哲学家先要拿起编号小的,再拿起编号大的筷子。
破除了循环等待,就不会出现死锁了。
如何避免死锁
死锁成因的四个必要条件
1.互斥使用(锁的基本特性)。当一个线程持有一把锁之后,另一个线程也想获取到锁,就要阻塞等待。
2.不可抢占(锁的基本特性)。当锁已经被线程1拿到后,线程2只能等线程1主动释放,不能强行抢过来。
3.请求保持(代码结构)。一个线程尝试获取多把锁(先拿到锁1后,再尝试获取锁2。获取时锁1不会释放)
4.循环等待/环路等待:等待的依赖关系形成环了(车、房锁、哲学家)
解决死锁的核心,就是破坏上面的这些必要条件:
对于3来说,需要调整代码结构,避免编写“嵌套”关系。
对于4来说,可以约定加锁的顺序,就可以避免循环等待。
针对锁进行编号,先加编号大/小的,再加编号小/大的(所有线程都要遵守规则)
四、volatile关键字
作用:1.保证内存可见性 2.禁止指令重排序
1.保证内存可见性
什么是内存可见性问题
计算机运行的程序/代码,经常要访问数据。这些依赖的数据,往往会存储在内存中(例如,当定义一个变量时,这个变量就存储在内存中)。当CPU使用这个变量的时候,就会把这个内存中的数据,先读出来,放到cpu寄存器中,再参与运算(load)。但是CPU读取内存的操作,相对于读取寄存器来说要非常慢(CPU其他操作都很快,但是涉及到读写内存,速度就降下来了)。为了解决上述问题,提高效率。此时的编译器就会对代码做出优化,把原本要读内存的操作,优化成读取寄存器,减少读内存的次数,达到提高效率的目的。
private static int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (isQuit==0){
//循环体内没有代码,意味着循环一秒就会执行很多次
}
System.out.println("t1线程退出");
});
t1.start();
Thread t2 =new Thread(()->{
System.out.println("请输入isQuit : ");
Scanner sc = new Scanner(System.in);
isQuit = sc.nextInt();
//一旦用户输入的值不为0,就会使t1线程执行结束
});
t2.start();
}
-
预期结束: 只要用户输入一个非0的值,线程t1就会结束循环并退出。
-
但是实际结果是:不管输入的是什么,线程1一直在循环,并没有结束。还是RUNNABLE状态。
这就是由于多线程引发的bug,同样是线程安全问题。
一个线程读、一个线程修改变量,也可能会出现问题。就是由于“内存可见性”情况引起的。
while (isQuit==0){
//循环体内没有代码,意味着循环一秒就会执行很多次
}
在代码的这个while循环内主要做两件事:
1.要通过load读取isQuit的值到寄存器中
2.通过cmp指令比较寄存器的值是否是0,决定是否进行循环
由于这个循环的速度非常快,短时间内会进行大量的循环。进行大量的load、cmp操作。此时,编译器/JVM发现:进行的这么多次load的结果是一样的,并且load非常耗费时间,一次load的时间可以做上万次cmp。因此,编译器自动做出来优化:在第一次循环的时候,才读了内存。后续的循环都不再读取内存了,直接从寄存器中读取isQuit的值。
由于在线程1的循环中,编译器不会读取isQuit的值了,读的都是寄存器中的值。在线程2当中修改isQuit,线程1感知不到内存发生了变化,就无法对线程1产生影响了。
编译器本意是好的,可惜执行坏了。在优化之后,修改了多线程情况下的执行逻辑。
上述的这个问题就是内存可见性问题。内存已经修改了,但是无法进行感知。
如何解决
使用volatile关键字
在多线程环境下,编译器对于是否优化的判断并不准确。此时需要程序员通过volatile关键字来告诉编译器不用优化。
这时,给isQuit变量前加上volatile关键字,编译器就会禁止上述优化。
private volatile static int isQuit = 0;
请输入isQuit :
1
t1线程退出
当我们在循环内部加上sleep
private static int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (isQuit==0){
//循环体内没有代码,意味着循环一秒就会执行很多次
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("t1线程退出");
});
t1.start();
Thread t2 =new Thread(()->{
System.out.println("请输入isQuit : ");
Scanner sc = new Scanner(System.in);
isQuit = sc.nextInt();
//一旦用户输入的值不为0,就会使t1线程执行结束
});
t2.start();
}
请输入isQuit :
1
t1线程退出
此时,没有加上volatile,t1线程却可以顺利退出。因为加了sleep后,while循环的速度变慢了,load操作的开销就不大了,就没有触发load的优化,也就没有触发内存可见性问题。
Java内存模型JMM
JMM(Java Memory Model) Java内存模型
是Java规范文档上的叫法
JMM把存储空间划分为了主内存和工作内存。t1线程对应的isQuit变量,本身是在主内存中的。由于此处的优化,就会把isQuit变量放到工作内存中,t2再修改主内存中的isQuit时,就不会影响到t1的工作内存。
主内存 main memory: 平常说的内存
工作内存 work memory:cpu寄存器 和缓存
Java是跨平台的,要兼容各种硬件,所以采用统一的术语
volatile不能保证原子性,保證的是内存可见性
五、wait 和 notify
-
用来协调多个线程的执行顺序
线程是系统随机调度,抢占式执行的。很多时候,会通过一些手段来调整执行的顺序。
join影响的是线程结束的先后顺序,从而影响执行的顺序。而希望线程在不结束的情况下,进行执行顺序的控制,就可以用wait和notify
-
wait(等待):让指定的线程进入阻塞状态
-
notify(通知):唤醒对应的阻塞状态的线程
1.wait
wait和notify都是Object的方法。随便定义一个对象,都可以使用wait 和 notify.
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println("wait之前");
object.wait();
System.out.println("wait之后");
}
- 此时如果进行执行,就会抛出异常
这个监视器就是synchronized (监视器锁),此时的含义就是非法的锁状态。
因为wait在执行的时候要做三件事。
1.释放当前的锁。
2.让线程进入阻塞。
3,当线程被唤醒的时候,重新获取到锁。
synchronized加锁就是把对象头的标记进行操作,而wait的第一步就是释放锁,如果之前就没加锁,就无法进行释放。所以会出现异常。所以要解决这个异常,就需要把wait放进 synchronized里面,确保已经拿到锁了,才能释放。
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object){
System.out.println("wait之前");
object.wait();
System.out.println("wait之后");
}
}
}
- 代码走到 object.wait()后线程就会一直阻塞等待下去,直到其他线程调用了对应的notify方法。
此时主线程中的状态就是WAITING状态。
wait还有一个带参数的方法:可以指定一个超时时间。从而避免wait无休止的等待下去。
object.wait(3000);
//最多等待三千毫秒
2.notify
public static void main(String[] args) {
Object object = new Object();
Thread t1 = new Thread(()->{
synchronized (object){
System.out.println("wait之前");
try {
object.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("wait之后");
}
});
Thread t2 = new Thread(()->{
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (object){
System.out.println("进行通知");
object.notify();
}
});
t1.start();
t2.start();
}
wait之前
进行通知
wait之后
先进行阻塞,直到三秒后线程2进行了唤醒
这样就可以实现多线程执行顺序的调整。需要注意的是,这套操作,只能让线程执行的时间后移,不能向前。
”线程饿死“
使用wait notify 也可以避免”线程饿死“
当1号线程加锁后,没有产生实际的操作,于是就进行了释放解锁。此时,后续的进程要想加锁,还需要一个系统的调度过程。但是由于1号进程已经在cpu上面执行了,所以1号线程更容易再次重新拿到锁,以此往复。这种情况叫做线程饿死
解决方法:让1号线程进行wait(wait本身就会对锁进行释放,并且对1号线程进行堵塞),1号线程就不会参与后续的锁竞争了,为后续的线程提供了机会。
notify:一次唤醒一个线程。相比之下notify更可控,用的更多。
notifyAll:一次唤醒全部线程
当有多个线程调用wait,这些线程就都会进入阻塞,此时唤醒要么一次唤醒一个线程,要么一次唤醒全部线程。(实际上唤醒的时候,wait要涉及到一个重新获取锁的一个过程,就会产生锁竞争,此时就是一个挨一个串行执行的,没轮到执行的是BLOCKED状态)
点击移步博客主页,欢迎光临~