java多线程04_线程同步

前面章节,我们学习了线程的创建和状态控制,但是每个线程之间几乎都没有什么太大的联系。可是有的时候,可能存在多个线程多同一个数据进行操作,这样,可能就会引用各种奇怪的问题。现在就来学习多线程对数据访问的控制吧。

由于同一进程的多个线程共享同一片存储空间,在带来方便的同时,也带来了访问冲突这个严重的问题。好在Java语言提供了专门机制以解决这种冲突,有效避免了同一个数据对象被多个线程同时访问。

1.线程安全概述

下面看一个经典的问题,关于银行取钱的问题:

假设你的银行账户有5000元,并且你和你的妻子两人都知道账户密码。某一天,你去银行取3000元,银行系统会先查看你的账户够不够3000元,明显你是满足条件的,但是,如果此时你的妻子也需要去取3000元,并且你的取钱线程刚好因为某些状况被打断了(这时系统还来不及修改你的账户余额),所以你的妻子去取钱时也满足条件,所以她完成了取钱动作,而你取钱线程恢复之后,你也将完成取钱动作。

【示例】通过代码模拟取钱的过程

// 银行账户类
class Account {
	// 账户余额
	private int balance = 5000;
	// 获取账户余额方法
	public int getBalance() {
		return balance;
	}
	// 取钱方法
	public void drawMoney(int money) {
		balance -= money;
	}
}
// Runnable类,模拟取钱的操作
public class AccountRunnable implements Runnable {
	// 银行账户对象
	private Account account;
	public AccountRunnable(Account account) {
		this.account = account;
	}
	@Override
	public void run() {
		// 先判断余额够不够,如果足够则取款
		if(account.getBalance() >= 3000) {
			try {
				Thread.sleep(1); // 取钱线程中断,起切换线程的作用
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			// 取款3000元
			account.drawMoney(3000);
			// 输出余额
			System.out.println(Thread.currentThread().getName() + "取款成功" 
			+ ",余额为:" + account.getBalance());
		}
		// 余额不够,则不允许取款
		else {
			System.out.println(Thread.currentThread().getName()
					+"取款失败,余额为:" + account.getBalance());
		}
	}
}
// 测试类,模拟你和你老婆取钱
public class Test {
	public static void main(String[] args) {
		// 实例化银行账户对象
		Account account = new Account();
		// 创建并开启线程
		AccountRunnable ar = new AccountRunnable(account);
		new Thread(ar, "你").start();
		new Thread(ar, "你老婆").start();
	}
}

执行以上代码,输出结果为:
在这里插入图片描述
通过运行结果观察,我们可以发现共享数据(账户余额)的完整性被破坏了,两个线程同时操作一个银行账户,银行账户明明只有5000元,结果两人却轻松取出了3000元,如果现实中真发生这种情况,估计银行就要哭晕在厕所了。

为了避免这样的事情发生,我们要保证线程同步互斥,所谓同步互斥就是:并发执行的多个线程在某一时间内只允许一个线程在执行以访问共享数据。就好比:教室里,只有一台电脑,多个人都想使用。天然的解决办法就是,在电脑旁边,大家排队。上一个人使用完后,下一个人再使用。

为了解决这个问题,java提供了线程同步机制,它能够解决上述的线程安全问题,线程同步的方式有两种:同步代码块和同步方法。

2.同步代码块详解

“非线程安全”其实就是在多个线程对同一个对象中的实例变量进行并发访问时发生,产生的后果就是“脏读”,也就是取到的数据其实是被更改过的的结果。在Java中,关键字synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作)。

在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,此时我们可以使用同步代码块。同步代码块,就是在代码块声明上加上synchronized关键字。

synchronized (同步监视器) {
	// 可能会产生线程安全问题的代码
}

同步监视器可以理解为就是一把锁,锁住了别的线程就进不去了,直到该线程释放掉这个锁(释放锁是指持锁线程退出了synchronized同步代码块)。

同步监视器注意事项:

  1. 同步监视器必须是引用数据类型,不能是基本数据类型。

  2. 可以改变同步监视器堆中属性的值,但是不能改变指向堆中的地址。

  3. 尽量不要用String和包装类型作为同步监视器,容易造成指向堆中地址的变化。

  4. 一般使用共享资源作为同步监视器,也可以专门创建一个没有任何语义化含义的同步监视器。

接下来我们通过同步代码块,对银行取钱案例中 AccountRunnable 类进行如下代码修改:

【示例】使用同步代码块模拟取钱

// Runnable类,模拟取钱的操作
public class AccountRunnable implements Runnable {
	// 银行账户对象
	private Account account;
	public AccountRunnable(Account account) {
		this.account = account;
	}
	@Override
	public void run() {
		// 同步代码块
		synchronized (this) {
			// 先判断余额够不够,如果足够则取款
			if(account.getBalance() >= 3000) {
				try {
					Thread.sleep(1); // 取钱线程中断,起切换线程的作用
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				// 取款3000元
				account.drawMoney(3000);
				// 输出余额
				System.out.println(Thread.currentThread().getName() + "取款成功" 
				+ ",余额为:" + account.getBalance());
			}
			// 余额不够,则不允许取款
			else {
				System.out.println(Thread.currentThread().getName()
						+"取款失败,余额为:" + account.getBalance());
			}
		}
	}
}

当使用了同步代码块后,上述的线程的安全问题,解决了。

synchronized(this)意味着线程需要获得this对象的“锁”才有资格运行同步中的代码。 this对象的“锁”也成为“互斥锁”,只能同时被一个线程使用。例如,A线程拥有锁,则可以调用“同步块”中的代码;B线程没有锁,则进入this对象的“锁池队列”等待,直到A线程使用完毕释放了this对象的锁,B线程才可以开始调用“同步块”中的代码。

补充:使用同步代码块时,同步监视器一般为this,即当前类对象,这是使用的最多的一种方式。

3.同步方法详解

当某一个方法中的所有代码都需要同步的时候,这时候我们可以使用同步方法,同步方法就是在方法声明上加添加 synchronized关键字。

  • 非静态同步方法

在非静态方法声明上添加synchronized,我们称之为非静态同步方法。

public synchronized void method() {
	// 可能会产生线程安全问题的代码
}

非静态同步方法和同步代码块的原理比较类似,不过非静态同步方法中的同步监视器是隐式的,该同步监视器默认为this(感兴趣的同学可以自己通过代码验证一下)。

接下来我们使用非静态同步方法,对银行取钱案例中 AccountRunnable 类进行如下代码修改:

【示例】使用非静态同步方法模拟取钱

// Runnable类,模拟取钱的操作
public class AccountRunnable implements Runnable {
	// 银行账户对象
	private Account account;
	public AccountRunnable(Account account) {
		this.account = account;
	}
	@Override
	public void run() {
		// 此处省略500句(与安全无关)
		drawMoney();
		// 此处省略500句(与安全无关)
	}
	/**
	 * 同步方法
	 */
	public synchronized void drawMoney() {
		// 先判断余额够不够,如果足够则取款
		if (account.getBalance() >= 3000) {
			try {
				Thread.sleep(1); // 起切换线程的作用
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			// 取款3000元
			account.drawMoney(3000);
			// 输出余额
			System.out.println(Thread.currentThread().getName() + "取款成功" + ",余额为:" + account.getBalance());
		}
		// 余额不够,则不允许取款
		else {
			System.out.println(Thread.currentThread().getName() + "取款失败,余额为:" + account.getBalance());
		}
	}
}

建议,不要在重写的run方法添加synchronized ,否则run方法中的任务都变为同步了。

目前我们已接触到的类中,StringBuffer、Hashtable和Vecter都属于线程安全类,这些线程安全类中的方法都为非静态同步方法,例如StringBuffer类中的append方法就是synchronized修饰的。
在这里插入图片描述

  • 静态同步方法

synchronized关键字不但能修饰实例方法,还能修饰静态方法。在静态方法的声明上添加synchronized,我们称之为静态同步方法。

public static synchronized void method() {
	// 可能会产生线程安全问题的代码
}

静态同步方法中同步监视器是隐式的,静态同步方法的同步监视器默认为“类名.class”。

synchronized修饰实例方法,实际上是对调用该方法的对象(也就是this)加锁,俗称“对象锁”;synchronized修饰静态方法,实际上是对该类对象加锁,俗称“类锁”。

另外,同步方法直接在方法声明上加synchronized synchronized

4.synchronized总结

总结1:synchronized关键字使用注意点

  1. 只能同步方法和代码块,而不能同步变量和类。

  2. 只有共享资源的读写访问才需要同步,如果不是共享资源,根本没有同步的必要。

  3. 编写线程安全的代码会使系统的总体效率会降低,并且容易发生死锁的情况。

  4. 无论是同步代码块还是同步方法,必须获得对象锁才能够进入同步代码块或同步方法进行操作。

  5. 如果是非静态同步方法,对象锁为方法所在的对象;如果是静态同步方法,对象锁为方法所在的类(唯一)。

  6. 如果两个线程要执行一个类中的静态同步方法或非静态同步方法,那么一次只能有一个线程能够执行方法,另一个需要等待,直到锁被释放。也就是说,如果一个线程在对象上获得一个锁,就没有任何其它线程可以进入(该对象的)类中的任何一个同步方法。

总结2:一个线程取得了同步锁,那么在什么时候才会释放掉呢?

  1. 同步方法或代码块正常结束。

  2. 使用return或 break终止了执行,或者抛出了未处理的异常。

  3. 当线程执行同步方法或代码块时,程序执行了同步锁对象的wait()方法。

总结3:synchronized的可重入性

在使用synchronized时,当一个线程得到一个对象锁后(只要该线程还没有释放这个对象锁),再次请求此对象锁时是可以再次得到该对象的锁的(例如:同步方法可以调用同一个对象的另一个同步方法)。

【示例】同步方法可以调用同一个对象的另一个同步方法

class TestRunnable implements Runnable {
	@Override
	public void run() {
		method01();
	}
	public synchronized void method01() {
		System.out.println("执行method01方法啦");
		// 在同步方法中调用同一个对象的另外一个同步方法 
		method02();
	}
	public synchronized void method02() {
		System.out.println("执行method02方法啦");
	}
}
// 测试类
public class Test {
	public static void main(String[] args) {
		new Thread(new TestRunnable()).start();
	}
}

可重入锁也支持在父子类继承的环境中。当存在父子类继承关系时,子类是完全可以通过“可重入锁”调用父类的同步方法。

【示例】在子类同步方法中调用父类的同步方法

// 父类
class Parent {
	public synchronized void parentMethod() {
		System.out.println("父类parentMethod方法被执行啦");
	}
}
// 子类
class ChildRunnable extends Parent implements Runnable {
	@Override
	public void run() {
		childMethod();
	}
	public synchronized void childMethod() {
		System.out.println("子类childMethod方法被执行啦");
		// 调用父类方法
		super.parentMethod();
	}
}
// 测试类
public class Test {
	public static void main(String[] args) {
		new Thread(new ChildRunnable()).start();
	}
}

总结4:同步不具有继承性,也就是同步不可以继承

【示例】子类重写父类的同步方法,子类重写的方法可以为非同步方法

//父类
class Parent {
	// 父类的method方法为同步方法
	public synchronized void method() {
		System.out.println("父类method方法");
	}
}
//子类
class Child {
	// 子类重写父类的method方法,但是子类方法可以不是同步方法
	public void method() {
		System.out.println("子类method方法");
	}
}

5.死锁详解

简单的说就是:线程死锁时,第一个线程等待第二个线程释放资源,而同时第二个线程又在等待第一个线程释放资源。这里举一个通俗的例子,例如在人行道上两个人迎面相遇,为了给对方让道,两人同时向一侧迈出一步,双方无法通过,又同时向另一侧迈出一步,这样还是无法通过。假设这种情况一直持续下去,这样就会发生死锁现象。

导致死锁的根源在于不适当地运用“synchronized”关键词来管理线程对特定对象的访问。比如有两个对象A 和 B 。第一个线程锁住了A,然后休眠1秒,轮到第二个线程执行,第二个线程锁住了B,然后也休眠1秒,然后有轮到第一个线程执行。第一个线程又企图锁住B,可是B已经被第二个线程锁定了,所以第一个线程进入阻塞状态,又切换到第二个线程执行。第二个线程又企图锁住A,可是A已经被第一个线程锁定了,所以第二个线程也进入阻塞状态。就这样,死锁造成了。

【示例】死锁情况的代码演示

// 线程任务类
class LockA implements Runnable {
	@Override
	public void run() {
		System.out.println("LockA 线程开始执行");
		while(true) {
			// 锁住LockA
			synchronized (Test.obj1) { 
				System.out.println("LockA 锁住 obj1");
				try {
					// 线程休眠10ms,给LockB线程执行的机会
					Thread.sleep(10); 
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				// 在线程LockA内部锁住LockB
				synchronized (Test.obj2) {
					System.out.println("LockA 锁住 obj2");
				}
			}
		}
	}	
}
// 线程任务类
class LockB implements Runnable {
	@Override
	public void run() {
		System.out.println("LockB 线程开始执行");
		while(true) {
			// 锁住LockB
			synchronized (Test.obj2) {
				System.out.println("LockB 锁住 obj2");
				try {
					// 线程休眠10ms,给LockB线程执行的机会
					Thread.sleep(10); 
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				// 在线程LockB内部锁住LockA
				synchronized (Test.obj1) {
					System.out.println("LockB 锁住 obj1");
				}
			}
		}
	}	
}
// 测试类
public class Test {
	// 线程LockA的同步监视器
	public static Object obj1 = new Object();
	// 线程LockB的同步监视器
	public static Object obj2 = new Object();
	// main方法,程序的入口
	public static void main(String[] args) {
		new Thread(new LockA()).start();
new Thread(new LockB()).start();
	}
}

第一个线程首先锁住了obj1 ,然后休眠。接着第二个线程锁住了obj2,然后休眠。在第一个线程企图在锁住obj2,进入阻塞。然后第二个线程企图在锁住obj1,进入阻塞。然后就进入死锁了。

死锁是线程间相互等待锁造成的,在实际中发生的概率非常的小。如果真的遇见死锁的这种情况,我们只避免死锁的发生,唯一的解决方案就是优化代码的逻辑。

ps:如需最新的免费文档资料和教学视频,请添加QQ群(627407545)领取。

  • 0
    点赞
  • 0
    评论
  • 0
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值