java多线程:线程同步synchronized(不同步的问题、队列与锁),死锁的产生和解决
0、不同步的问题
并发的线程不安全问题:
多个线程同时操作同一个对象,如果控制不好,就会产生问题,叫做线程不安全。
我们来看三个比较经典的案例来说明线程不安全的问题。
0.1 订票问题
例如前面说过的黄牛订票问题,可能出现负数或相同。
0.2 银行取钱
再来看一个取钱的例子:
/*
模拟一个账户
*/
class Account{
int money;
String name;
public Account(int money, String name) {
this.money = money;
this.name = name;
}
}
/*
模拟取款机,方便设置名字,继承Thread而不是实现Runnable
*/
class Drawing extends Thread{
Account account;
int outMoney;//取出去了多少钱
int outTotal;//总共取到了多少钱
public Drawing(Account account, int outMoney,String name) {
super(name);
this.account = account;
this.outMoney = outMoney;
}
@Override
public void run() {
account.money -= outMoney;
outTotal += outMoney;
System.out.println(this.getName() + "---账户余额为:" + account.money);
System.out.println(this.getName() + "---总共取到了:" + outTotal);
}
}
然后我们写个客户端调用一下,假设两个人同时取钱,操作同一个账户
public class Checkout {
public static void main(String[] args) {
Account account = new Account(200000,"礼金");
Drawing you = new Drawing(account,8000,"你");
Drawing wife = new Drawing(account,300000,"你老婆");
you.start();
wife.start();
}
}
运行起来,问题就会出现。
每次的结果都不一样,而且,这样肯定会把钱取成负数,显然这是非法的(嘻嘻),首先逻辑上需要修改,当钱少于 0 了就应该退出,并且不能继续取钱的动作了。按照这个思路,加上一个判断呢?
if (account.money < outMoney){
System.out.println("余额不足");
return;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
可是即便是这样,发现还是会出现结果为负的情况,无法保证线程安全。
0.3 数字递增
还有一个经典的例子,那就是对于直接计算迭代过慢,而转为多线程。
一个数字 num ,开辟一万个线程对他做 ++ 操作,看结果会是多少。
public class AddSum {
private static int num = 0;
public static void main(String[] args) {
for (int i=0; i<=10000; i++){
new Thread(()->{
num++;
}).start();
}
System.out.println(num);
}
}
每次运算的结果都不一样,一样的是,结果永远 < 10000 。
或者用给 list 里添加数字来测试:
List<String> list = new ArrayList<>();
for (int i=0; i<10000; i++){
new Thread(()->{
list.add(Thread.currentThread().getName());
}).start();
}
System.out.println(list.size());
一样的结果。
线程不安全的问题如何解决呢?
一、同步(synchronized)
1.1 问题出现的原因
从前面的介绍里,我们总结出会出现同步问题的情况,也就是并发三要素:多个线程、同时操作、操作同一个对象。另外,操作的特点是:操作类型为修改。这个时候会产生并发的问题,线程安全问题。
1.2 解决方案
- 确保线程安全,第一就是排队。只要排队,那么不管多少线程,始终一个时间点只会有一个线程在执行,就保证了安全。
不过排队会有一个问题:怎么直到轮到我了呢,也就是怎么知道排在前面的线程执行完了呢? - 现实生活中,可能会用类似房卡的形式,前一个人把卡交还了,才会有后面的人有机会入住。这就是锁。
利用 队列 + 锁 的方式保证线程安全的方式叫线程同步,就是一种等待机制,多个同时访问此对象的线程进入这个对象的等待池 形成队列,前面的线程使用完毕后,下一个线程再使用。
锁机制最开始在 java 里就是一个关键字 synchronized(同步),属于排他锁,当一个线程获得对象的排他锁,独占资源,其他线程必须等待,使用后释放锁即可。
按照这种思路,可以想象到这种保证安全方式的弊端,也就是早期的 synchronized 存在的问题:
- 一个线程持有锁会导致其他所有需要这个锁的线程挂起;
- 多线程竞争下,加锁、释放锁导致耗时严重,性能问题;
- 一个优先级高的线程等待一个优先级低的线程的锁释放,会使得本应该的优先级倒置,引起性能问题。
另外,Synchronized 是基于底层操作系统的 Mutex Lock 实现的,每次获取和释放锁操作都会带来用户态和内核态的切换,从而增加系统性能开销。因此,在锁竞争激烈的情况下,Synchronized 同步锁在性能上就表现得非常糟糕,它也常被大家称为重量级锁。
但是 jdk 6 之后有了很强的改进,这个内容待更新,留个坑。
二、同步关键字的用法
2.1 同步方法
synchronized 方法控制对 成员变量或者类变量 对象的访问,每个对象对应一把锁。写法如下:
public synchronized void test(){
//。。。
}
- 如果修饰的是具体对象:锁的是对象;
- 如果修饰的是成员方法:那锁的就是 this ;
- 如果修饰的是静态方法:锁的就是这个对象.class。
每个 synchronized 方法都必须获得调用该方法的对象的锁才能执行,否则所属的这个线程阻塞,方法一旦执行,就独占该锁,直到从该方法返回时,锁释放。
同步方法的写法代码,以上面的取钱案例历的 取钱类为例,如果直接在提款机的操作,把 run 方法或者里面的内容提出来变成 test ,加上 synchronized 修饰:
@Override
public void run() {
test();
}
public synchronized void test(){
//内容都不变
}
会发现,仍然出现了负数。锁定失败。
分析:
我们认为在 test 方法里进行的对象修改,所以把他锁上就好了,但是对于这个类,这个提款机类来说,test 方法是成员方法,因此锁的对象实际上是 this ,也就是提款机。
但我们的初衷,要线程锁的资源应该是 Account 对象,而不是提款机对象。
2.2 同步块
除了方法,synchronized 还可以修饰块,叫做同步块。
synchronized 修饰同步块的方式是:
synchronized (obj){
//...
}
其中的 obj 可以是任何对象,但是用到它,肯定是设置为那个共享资源,这个 obj 被称为同步监视器。同步监视器的作用就是,判断这个监视器是否被锁定(是否能访问),从而决定是否能执行其中的代码。
java的花括号中内容有以下几种:
- 方法里面的块:局部块。解决变量作用域的问题,快速释放内存(比如方法里面再有个for循环,里面的变量);
- 类层的块:构造块。初始化信息,和构造方法是一样的;
- 类层的静态块:静态构造快。最早加载,不是对象的信息,而是类的信息;
- 方法里面的同步块:监视对象。
第四种就是我们这里学习的同步块。
注意,如果是同步方法里,没必要指定同步监视器,因为同步方法的监视器已经是 this 或者 .class。
用同步块的方式对提款机问题进行修改:
public void test(){
synchronized(account){
//内容不变
}
}
也就是加上对 account 的监视器,锁住这个对象。这样运行结果就正确了 。
这种做法效率不高,因为虽然对 account 上了锁,但是每一次都要把整个流程走一遍,方法体的内容是很多的,另外,每次加锁与否,都是性能的消耗,进入之后再出来,哪怕什么也不做,也是消耗。
其实,我们可以在加锁的前面再加一重判断,那么之后就没必要再进行上锁的过程了。
public void test(){
if (account.money ==0 ){
return;
}
synchronized(account){
}
}
就是这样的一个代码,在并发量很高的时候,往往可以大大提高效率。
对于上面的 10000 个线程的加法那个问题,我们也可以通过 synchronized 加锁,来保证结果的正确性。
(但是 synchronized 修饰的要是引用类型,所以直接对 int num 加锁不行,一般直接使用专门提供的原子类)
list 的里加数字的测试:
List<String> list = new ArrayList<>();
for (int i=0; i<10000; i++){
new Thread(()->{
synchronized (list){
list.add(Thread.currentThread().getName());
}
}).start();
}
Thread.sleep(2000);
System.out.println(list.size());
main方法,下面的print语句,这些都是线程,所以可能上面还没有操作的时候,就已经输出了,为了方便观察,我们在最后输出之前先让main线程休眠一会,再看里面add的结果是否正确。
tips:对于容器的操作,Java的util.concurrent包里也直接提供了对应的安全容器CopyOnWriteArrayList。
CopyOnWriteArrayList<String > list1 = new CopyOnWriteArrayList<>();
for (int i=0; i<10000; i++){
new Thread(()->{
list1.add(Thread.currentThread().getName());
}).start();
}
Thread.sleep(2000);
System.out.println(list1.size());
2.3 问题
synchronized 块太小,可能锁不住,安全性又不行了,锁的方法太大,又效率会降低,所以要很注意控制范围。
而且,还有类似于 单例模式 里 Double-Check 写法针对的问题,有时候一重锁性质不够,两重锁仍然不够保证安全。
三、线程同步问题应用示例
3.1 快乐影院
电影院买票。
/**
* 快乐影院
*/
public class HappyCinema {
public static void main(String[] args) {
Cinema cinema = new Cinema(20, "万达");
new Thread(new Customer(cinema,2)).start();
new Thread(new Customer(cinema,1)).start();
}
}
/**
* 电影院,提供订票方法
*/
class Cinema{
int available;
String name;
public Cinema(int available, String name) {
this.available = available;
this.name = name;
}
//提供购票方法
public boolean bookTickets(int seats){
System.out.println("可用位置为:"+available);
if (seats > available){
return false;
}
available -= seats;
return true;
}
}
/**
* 顾客,有多个顾客,模仿多线程
*/
class Customer implements Runnable{
Cinema cinema;
int seats;
//顾客创建的时候带上要预定的作为+订哪个影院
public Customer(Cinema cinema, int seats) {
this.cinema = cinema;
this.seats = seats;
}
@Override
public void run() {
boolean flag = cinema.bookTickets(seats);
if (flag){
System.out.println("出票成功,"+Thread.currentThread().getName()+"买了 "+seats+" 张票");
}else{
System.out.println("出票失败,"+Thread.currentThread().getName()+"买票,但位置不足 ");
}
}
}
对于一个电影院的票:available 资源来说,多个线程访问,是需要同步的,否则就会出现不安全的问题。
解决:
@Override
public void run() {
synchronized (cinema){
//。。。
}
}
}
3.2 快乐影院进阶
影院票的时候不是简单计数,是可以选座位的,我们修改代码,具体到某一个座位号的预定。
将 int 座位数目改成 List,那么购票方法改动如下:
public boolean bookTickets(List<Integer> seats){
System.out.println("可用位置为:" + available);
List<Integer> copy = new ArrayList<>(available);
//相减
copy.removeAll(seats);
//判断改变后
if (available.size() != copy.size() + seats.size() ){
return false;
}
available = copy;
return true;
}
其他地方只需要做简单的修改,在调用的时候传入一个构造好的 list 即可,这个时候再来看:
如果两个顾客同时订票的位置冲突
可以看到完成了同步。
3.3 火车票
还是类似于订票,因为上面电影院的部分我们都使用 同步块 的方式锁定某个对象,这里使用同步方法来加深上锁的理解。
模仿第一种电影院订票的初始不加锁写法。
public class Happy12306 {
public static void main(String[] args) {
Railway railway = new Railway(20, "京西G12138");
new Thread(new Passenger(railway,2)).start();
new Thread(new Passenger(railway,1)).start();
}
}
/**
* 铁路系统,提供订票方法
*/
class Railway{
int available;
String name;
public Railway(int available, String name) {
this.available = available;
this.name = name;
}
//提供购票方法
public boolean bookTickets(int seats){
System.out.println("可用位置为:"+available);
if (seats > available){
return false;
}
available -= seats;
return true;
}
}
/**
* 顾客,有多个顾客,模仿多线程
*/
class Passenger implements Runnable{
Railway railway;
int seats;
public Passenger(Railway railway, int seats) {
this.railway = railway;
this.seats = seats;
}
@Override
public void run() {
boolean flag = railway.bookTickets(seats);
if (flag){
System.out.println("出票成功,"+Thread.currentThread().getName()+"买了 "+seats+" 张票");
}else{
System.out.println("出票失败,"+Thread.currentThread().getName()+"买票,但位置不足 ");
}
}
}
现在开始给方法加锁,考虑这个问题:
- 本来的 run 方法写了 同步块 对一个资源加锁,这个资源是 票所在的 铁路系统(上一个例子的电影院);
- 所以如果锁 run 方法,我们前面说过的,锁成员方法相当于锁的 this,也就是锁了 乘客 类,是没有用的,因为被修改的资源不在这里;
- 应该将这个方法放到 铁路系统 类里,然后对这个方法上锁。
这样会带来新的问题,模拟多个线程的线程体应该来源于 乘客 ,不能是铁路系统,所以乘客类也要继续修改,继承 Thread 类,本身作为一个代理,去找到目标接口的实现类:铁路系统 ,然后start。
public class Happy12306 {
public static void main(String[] args) {
Railway railway = new Railway(5, "京西G12138");
new Passenger(5,railway,"乘客B").start();
new Passenger(2,railway,"乘客A").start();
}
}
/**
* 铁路系统,提供订票方法,本身就是一个线程,
*/
class Railway implements Runnable{
int available;
String name;
public Railway(int available, String name) {
this.available = available;
this.name = name;
}
//提供购票方法,加入同步
public synchronized boolean bookTickets(int seats){
System.out.println("可用位置为:"+available);
if (seats > available){
return false;
}
available -= seats;
return true;
}
//run方法从 顾客类里 挪过来,
@Override
public void run() {
//运行时需要知道哪个线程在操作自己,也就是seats的来源
Passenger p = (Passenger) Thread.currentThread();
boolean flag = this.bookTickets(p.seats);
if (flag){
System.out.println("出票成功,"+Thread.currentThread().getName()+"买了 "+p.seats+" 张票");
}else{
System.out.println("出票失败,"+Thread.currentThread().getName()+"买票,但位置不足 ");
}
}
}
/**
* 顾客,作为代理,是 Thread 的子代理
*/
class Passenger extends Thread{
int seats;
public Passenger(int seats, Runnable target, String name) {
super(target,name);//用父类方法找到目标,也就是铁路系统
this.seats = seats;
}
}
总结:
- synchronized 修饰成员方法锁定的是 this,所以要加入铁路系统类,
- 铁路系统通过 Thread.currentThread() 方法 确定当前的线程,同时获取到订票信息;
- 乘客变成了 代理,是 Thread 的子类,在这个基础上加入订票信息;
- 最后调用的时候,本应该使用 Thread 作为代理去执行,改为用乘客类,起到了一个系统用多个不同线程的作用。
乘客本身作为代理子类可能比较难理解。
但是我们回头看看,对于上一种方式:
new Thread(new Passenger(railway,2)).start();
new Thread(new Passenger(railway,1)).start();
虽然这么写的,但是其实传入的一个 Runnable的实现类,在 Thread 源码里面调用了构造方法:
可以看到,传入一个 Runnable ,这个构造器加上了额外的信息,所以其实我们这种做法:
public Passenger(int seats, Runnable target, String name) {
super(target,name);//用父类方法找到目标,也就是铁路系统
this.seats = seats;
}
是模拟了源码的写法而已。
四、多线程死锁的产生与解决
4.1 问题
死锁:当多个线程各自占有一些共享资源,并且互相等待其他线程占有的资源才能进行,从而导致两个或者多个线程都在等待对方释放资源,都停止执行的情况。
最简单的,某一个同步块同时拥有“两个以上的对象的锁”的时候,就可能会发生死锁问题。
- 如果两个线程,那就是涂口红、照镜子的问题,每个人都想先拿了一个再拿另一个;
- 如果是多个线程,对应哲学家就餐问题,每个人都想左手拿刀、右手拿叉。
口红镜子问题示例:
/**
* 死锁的产生
*/
public class DeadLock {
public static void main(String[] args) {
Makup makup = new Makup(1,"女孩1");
Makup makup1 = new Makup(0,"女孩2");
makup.start();
makup1.start();
}
}
/**
* 口红
*/
class Lipstick{ }
/**
* 镜子
*/
class Mirror{ }
/**
* 化妆
*/
class Makup extends Thread{
static Lipstick lipstick = new Lipstick();
static Mirror mirror = new Mirror();
int choice;//选择
String girl;
public Makup(int choice, String girl){
this.choice = choice;
this.girl = girl;
}
@Override
public void run() {
makeup();
}
//相互持有对方的对象锁
private void makeup(){
if (choice == 0){
synchronized (lipstick){
System.out.println(this.girl + "获得口红");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (mirror){
System.out.println(this.girl + "然后获得镜子");
}
}
}else{
synchronized (mirror){
System.out.println(this.girl + "获得镜子");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lipstick){
System.out.println(this.girl + "然后获得口红");
}
}
}
}
}
可以发现程序停不下来了,死锁已经产生。
其中的过程就是:
- 女孩 1 先拿到了镜子,对其上锁;
- 女孩 1 休息的时候,女孩 2 先拿到了口红,对其上锁;
- 女孩 2 休息的时候,女孩 1 休息结束,想要获取口红,但此时口红上锁,因此等待;
- 女孩 2 休息结束,想要获取镜子,但此时镜子上锁,因此等待。
4.2 解决
解决这个问题的方法:
不要出现 锁的 嵌套 ,将等待后获取另一个锁的代码放到第一个加锁的后面就可以解决这个问题了:
private void makeup(){
if (choice == 0){
synchronized (lipstick){
System.out.println(this.girl + "获得口红");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (mirror){
System.out.println(this.girl + "然后获得镜子");
}
}else{
synchronized (mirror){
System.out.println(this.girl + "获得镜子");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (lipstick){
System.out.println(this.girl + "然后获得口红");
}
}
}
总结:尽量不要让 一个同步代码块 同时拥有“两个以上的对象的锁”。