前言
在此之前我们已经知道了使用 synchronized 可以解决并发过程中,线程抢占执行所带来的数据错乱问题。本篇文章将会详细介绍 synchronized 是怎么解决线程抢占问题的,以及使用不当所带来的一系列问题。
往期回顾:
✨【Java 并发编程】解决多线程中数据错乱问题
✨【Java 并发编程】多线程安全问题
目录
前言
同步锁
同步锁的介绍
同步锁的原理
同步锁的细节
理解同步锁
同步方法的对象
加同步锁的效率
同步锁的练习
可重入锁
可重入锁的介绍
死锁
死锁的介绍
同步锁
同步锁的介绍
同步锁,主要是用来解决两个或者两个以上的线程共享存取相同一份数据的问题。根据上期的内容,可以知道在 Java 中的一条自增语句,在底层要用三条指令来完成:
(1)将内存的值读取到寄存器中 -- get (2)在寄存器中实现数据的自增 -- modify (3)将寄存器的值写入内存中 -- set
并发过程中线程抢占式执行,会导致数据被破坏,造成数据的随机性。如上图 i = 1 这种情况,由于两个线程同时执行,同时去操作 i 的值。那么如何避免以上这种情况呢?只需要将并行执行变成串行执行,这里就要借助 synchronized 同步锁。
同步锁的特点:在多个线程访问同一个共享资源的时候,在同一个时刻只允许一个线程访问。
同步锁的原理
拿上期的代码举个例子:
class Test {public static int Count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for(int i = 0;i<50000;i++){synchronized (Test.class) {Count++;}}});Thread t2 = new Thread(()->{for(int i = 0;i<50000;i++){synchronized (Test.class) {Count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(Count);}
}
我们来看这个同步代码块,首先由于程序是并发的,所以线程会抢占式执行,以上 t1、t2、t3 等线程都有被执行到的概率。假定 t1 线程抢到了 “门” (先进入方法),这个时候 synchronized 就会自动加锁,其他线程就进不来了,除非 t1 线程执行完并将锁释放。
进⼊ synchronized 修饰的代码块, 相当于 加锁 |
退出 synchronized 修饰的代码块, 相当于 解锁 |
由于 synchronized 是非公平锁,当 t1 线程释放掉锁后,所有的线程都将同时竞争锁,也就是说 t1 线程还是可以拿到锁的。像这样每次进行 count 自增操作时,都用锁来规范程序的行为(将并发执行变为串行执行),这样虽然损失了效率,但是挺高了线程安全。
同步锁的细节
在 Java 语言中,同步锁也叫互斥对象锁:“互斥”的意思是当一个线程拿到锁,就会排斥其他线程,让它们进入 BLOCKED (堵塞)状态,直到当前线程运行完程序释放掉锁,堵塞的其他线程才能重新竞争锁。“对象”的意思是,每个对象都对应一个可称为 “互斥锁” 的标记,这个标记用来保证每一个适合,只能有一个线程访问该对象。
以上述例子为例,若是锁住的是两个不同的对象,还能保证数据的正确吗?
class Test {public static int Count = 0;public static void main(String[] args) throws InterruptedException {Object o1 = new Object();Object o2 = new Object();Thread t1 = new Thread(()->{for(int i = 0;i<50000;i++){synchronized (o1) {Count++;}}});Thread t2 = new Thread(()->{for(int i = 0;i<50000;i++){synchronized (o2) {Count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(Count);}
}
运行结果:
(1)87234
(2)88161
(3)91899...
根据程序的多次运行,发现结果总是随机且小于 100000 的数,原因是只要有锁就已经满足了进入同步方法或者同步代码块的资格,而不同的对象产生的锁也是不同的,以上 t1 线程拿到了 o1 对象的锁,t2 线程拿到了 o2 对象的锁,两个线程都可以自由的访问 run ,程序就又并发执行了。
理解同步锁
这里举个大家都喜欢举的 “上厕所” 的例子 ~
假设厕所只有一个门(只对一个对象加锁),当一个人抢到了厕所,就立刻锁上门,这个时候其他想要上厕所的人就必须等待这个人上完了厕所并且开了门,才能重新进入厕所。
理解阻塞等待
针对每⼀把锁,操作系统内部都维护了⼀个等待队列。当这个锁被某个线程占有的时候, 其他线程尝试进⾏加锁,就加不上了,就会阻塞等待。⼀直等到之前的线程解锁之后, 由操作系统唤醒⼀个新的线程,再来获取到这个锁。
那么一个同步方法或者同步代码块对多个对象加锁无异于这个厕所有多个门,另一个人便可以从另一扇门进入这个厕所,当然这样的设计肯定是不合理的。你愿意另一个人和你一起蹲坑吗?
同步方法的对象
Synchronized 想要获取锁有三种应用方式
修饰实例成员方法:使用this 锁,线程想要执行被Synchronized 关键字修饰的普通方法,必须先获取当前实例对象的锁资源 |
修饰静态成员方法:使用class 锁,线程想要执行被Synchronized 关键字修饰的静态方法,必须先获取当前类对象的锁资源 |
修饰代码块:使用Object 锁,使用给定的对象实现锁功能,线程想要执行被Synchronized 关键字修饰的代码块,必须先获取当前给定对象的锁资源 |
class Count {private int count = 0;public synchronized void Add() {count++;}public int getCount() {return count;}
}class Test1 {public static void main(String[] args) throws InterruptedException {Count c = new Count();Thread t1 = new Thread(()->{for(int i=0;i<500;i++) {c.Add();}});Thread t2 = new Thread(()->{for(int i=0;i<500;i++) {c.Add();}});t1.start();t2.start();t1.join();t2.join();System.out.println(c.getCount());}
}
public synchronized void Add() {count++;}
synchronized 加在非静态的同步方法上,默认获取的对象就是 this
public void Add() {synchronized (this){count++;}}
public synchronized static void Add() {count++;}
synchronized 加在静态的同步方法上,默认获取的对象就是当前类的本身
private static int count = 0;static public void Add() {synchronized (Count.class){count++;}}
关于代码块的锁对象,其实只要是一个对象就行,可以是当前对象的 this,也可以是当前类的class, 也可以用 Object 或者 Object 的子类创建的对象都可以。
加同步锁的效率
我们知道给方法或者代码块加 synchronized 会将并发变成串行,其实实际开发上一般很少给一整个方法加锁,因为这很影响程序运行效率(整个方法都是串行的)。只需给数据会发生错乱的、会发生线程安全问题的代码块加上 synchronized 就能保证程序并发、并行同时执行,这样也比单线程或者单独给整个方法加锁的效率更高。
同步锁的练习
模拟写一个银行转账的代码
// 模拟设计一个银行转账实现
public class Bank {// 账户资金private int[] account;public Bank() {this.account = new int[10];for (int i = 0; i < 10; i++) {account[i] = 1000;}}public void transfer(int from,int to,int amount) {if(amount <= account[from]) {account[from] -= amount;account[to] += amount;}else{System.out.println(Thread.currentThread().getName()+" 资金不足 ");}}public int getTotalBalance(){int count = 0;for (int i = 0; i < 10; i++) {count += account[i];}return count;}}class Person extends Thread {Bank bank;private int Count = 0;public Person(Bank bank) {this.bank = bank;}@Overridepublic void run() {Random random = new Random();while(true){int from,to;while(true){from = random.nextInt(10);to = random.nextInt(10);if(from != to){break;}}int amount = random.nextInt(1000);synchronized(bank){bank.transfer(from, to, amount);Count += amount;}System.out.println(Thread.currentThread().getName() + " (从 " + from + " 转账到 " + to + " )" + amount + " 元"+" 一共转账:" + Count+"元");try {sleep(100);} catch (InterruptedException e) {return;}}}
}class Test{public static void main(String[] args) throws InterruptedException {Bank bank = new Bank();Person[] person = new Person[2];person[0] = new Person(bank);person[1] = new Person(bank);person[0].start();person[1].start();Thread.sleep(10000);person[0].interrupt();person[1].interrupt();System.out.println("总金额:"+bank.getTotalBalance());}
}
可重入锁
可重入锁的介绍
可重入锁顾名思义是锁嵌套,简单来说就是一个线程抢占到了同步锁资源,并且在释放锁之前再去竞争同一把锁的时候,不需要等待,只需要记录重入的次数。可重入锁解决的问题主要是避免了死锁的情况。
这里举个简单的例子:
class TestA extends Thread{synchronized void Fun1(){Fun2();}synchronized void Fun2(){Fun3();}synchronized void Fun3(){System.out.println("Hello World");}@Overridepublic void run() {Fun1();}}class Teat{public static void main(String[] args) throws InterruptedException {TestA t1 = new TestA();t1.start();t1.join();}
}
程序运行结果:
Hello World
为了更清晰的理解,我将各个方法中的 synchronized 整合在一起:
synchronized void Fun1(){synchronized (this){synchronized (this){System.out.println("Hello World");}}}
这里先假设 Java 中没有可重入锁的概念,那么遇到这种对同一个对象的锁嵌套问题是怎么样的呢?
不可重入所导致的问题
碰到以上这种情况就是我们常见锁 “锁冲突” 问题,此时代码(2)需要加的锁已经被代码(1)占用了,而代码(1)想要释放锁就必须运行完程序。这样程序就一直 “堵” 在这里,形成了 “死锁” 的局面。
而 Java 为了解决这一类问题,于是就提出来可重入锁的概念。如上述所说:一个线程抢占到了同步锁资源,并且在释放锁之前再去竞争同一把锁的时候,不需要等待,只需要记录重入的次数。那么计入 重入的次数 有什么用呢?
我们知道什么时候加锁(第一个 synchronized ),但是你知道什么时候释放锁吗?
你也许会说执行完第一个 synchronized 代码块的所有代码,但是编译器如何知道当前就是呢?所以这里就需要一个计数器。当计数器的值再次为 0 时就可以释放锁了。
死锁
死锁的介绍
死锁简单来讲就是两个或者两个以上的线程在执行的过程中,去争夺同样一份共享资源,造成相互等待的一个现象。如果没有外部干预,线程将会一直阻塞,无法往下去执行。像这种相互等待资源的线程,称之为 “死锁” 线程。
导致死锁的四个条件:
1.互斥条件,共享资源X和Y只能被一个线程占用 2.请求和保持条件,线程T1已经取得共享资源 X在等待共享资源Y的时候,不释放共享资源X 3.不可抢占条件,其他线程不能强行抢占线程T1占有的资源 4.循环等待条件,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源就是循环等待 导致死锁后,只能人工干预来解决,比如说重启服务,或者删掉这个线程,所以我们只能在写代码的时候去规避可能出现的死锁问题。而按照死锁发生的四个条件,只需破坏其中任意一项就可以去解决它。但是互斥条件是没有办法被破坏的,因为它是互斥锁的基本约束,而其他三项都有办法来破坏。
举一个死锁例子:
// 死锁案例
class Demo extends Thread{static Object o1 = new Object();static Object o2 = new Object();private boolean flag;public Demo(boolean flag) {this.flag = flag;}@Overridepublic void run() {if(flag == true){synchronized (o1){// o1 要释放锁,需要o2释放锁System.out.println(Thread.currentThread().getName()+"进入站台1");synchronized (o2){System.out.println(Thread.currentThread().getName()+"进入站台2");}}}else{synchronized (o2){// o2 要释放锁,需要o1释放锁System.out.println(Thread.currentThread().getName()+"进入站台3");synchronized (o1){System.out.println(Thread.currentThread().getName()+"进入站台4");}}}}
}public class Test {public static void main(String[] args) throws InterruptedException {Demo demo1 = new Demo(true);demo1.setName("线程A");Demo demo2 = new Demo(false);demo2.setName("线程B");demo1.start();demo2.start();demo1.join();demo2.join();}
}
程序运行结果:
线程B进入站台3
线程A进入站台1
以上这种情况就是:线程A等待线程B占有的o2锁资源,线程B等待线程A占有的o1锁资源。由于锁资源无法释放,所以造成了死锁的局面。对于循环等待这种条件,我们可以按序来申请资源来预防,申请的时候可以先申请资源序号小的,在申请资源序号大的,这样线性化之后呢,就自然不存在循环了。
@Overridepublic void run() {if(flag == true){synchronized (o1){System.out.println(Thread.currentThread().getName()+"进入站台1");synchronized (o2){System.out.println(Thread.currentThread().getName()+"进入站台2");}}}else{synchronized (o1){System.out.println(Thread.currentThread().getName()+"进入站台3");synchronized (o2){System.out.println(Thread.currentThread().getName()+"进入站台4");}}}}
}
运行结果:
线程A进入站台1
线程A进入站台2
线程B进入站台3
线程B进入站台4
这样程序就不会出现死锁问题了。对于其他条件,比如请求保持这个条件,我们可以一次性申请所有的资源,这样的话就不存在锁要等待了。对于不可抢占这个条件,占用部分资源的线程在进一步申请其他资源的时候,如果申请不到,我们可以主动去释放它占有的资源,这样不可抢占的条件就会被破坏掉。