Java面向对象系列[v1.0.0][线程同步]

线程安全问题

如下代码使用两个线程模拟取钱操作,模拟两个人使用同一个账户并发取钱的过程

public class Account
{
	// 封装账户编号、账户余额的两个成员变量
	private String accountNo;
	private double balance;
	public Account(){}
	// 构造器
	public Account(String accountNo, double balance)
	{
		this.accountNo = accountNo;
		this.balance = balance;
	}
	// 此处省略了accountNo和balance的setter和getter方法

	// accountNo的setter和getter方法
	public void setAccountNo(String accountNo)
	{
		this.accountNo = accountNo;
	}
	public String getAccountNo()
	{
		return this.accountNo;
	}

	// balance的setter和getter方法
	public void setBalance(double balance)
	{
		this.balance = balance;
	}
	public double getBalance()
	{
		return this.balance;
	}

	// 下面两个方法根据accountNo来重写hashCode()和equals()方法
	public int hashCode()
	{
		return accountNo.hashCode();
	}
	public boolean equals(Object obj)
	{
		if (this == obj)
			return true;
		if (obj != null && obj.getClass() == Account.class)
		{
			var target = (Account) obj;
			return target.getAccountNo().equals(accountNo);
		}
		return false;
	}
}

提供一个取钱的线程类,该线程类根据执行账户、取钱数量进行取钱操作,取钱的逻辑是当余额不足时无法提取现金,余额足够时系统吐出钞票,余额减少

public class DrawThread extends Thread
{
	// 模拟用户账户
	private Account account;
	// 当前取钱线程所希望取的钱数
	private double drawAmount;
	public DrawThread(String name, Account account,
		double drawAmount)
	{
		super(name);
		this.account = account;
		this.drawAmount = drawAmount;
	}
	// 当多条线程修改同一个共享数据时,将涉及数据安全问题。
	public void run()
	{
		// 账户余额大于取钱数目
		if (account.getBalance() >= drawAmount)
		{
			// 吐出钞票
			System.out.println(getName() + "取钱成功!吐出钞票:" + drawAmount);
			try
			{
				Thread.sleep(1);
			}
			catch (InterruptedException ex)
			{
				ex.printStackTrace();
			}
			// 修改余额
			account.setBalance(account.getBalance() - drawAmount);
			System.out.println("\t余额为: " + account.getBalance());
		}
		else
		{
			System.out.println(getName() + "取钱失败!余额不足!");
		}
	}
}

主线程很简单,创建一个账户,并启动两个线程取钱

public class DrawTest
{
	public static void main(String[] args)
	{
		// 创建一个账户
		var acct = new Account("1234567", 1000);
		// 模拟两个线程对同一个账户取钱
		new DrawThread("甲", acct, 800).start();
		new DrawThread("乙", acct, 800).start();
	}
}

因为系统的线程调度具有一定的随机性,在进行多线程编程的时候经常会遇到偶现的错误,执行上面的代码只要次数够多,就会出现账户余额只有1000时取出了1600,而账面余额变成了负数,如果这真的是一段银行取钱的多线程代码,显然是不行的,虽然代码中只是使用了sleep(1)来强制线程调度切换,但这种切换也是完全可能发生的只要执行次数足够多
多线程访问同一个数据时,很容易偶现这种线程安全问题,程序中有两个并发线程在修改Account对象,而且系统恰好在try代码块处切换给另一个修改Account对象的线程,所以就出现了问题

同步代码块

为了解决线程安全的问题,Java的多线程引入了同步监视器来解决这个问题,使用同步监视器的通用方法就是同步代码块,语法如下

synchronized(obj)
{
	...
}

obj就是同步监视器,这个代码的意义在于线程开始执行同步代码块之前,必须先获得对同步监视器的锁定,因此任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完成后,该线程会释放对该同步监视器的锁定,其作用一目了然就是组织两个线程对同一个共享资源进行并发访问,也因此一般情况下是使用可能被并发访问的共享资源作为同步监视器
之前的代码可修改为:

public class DrawThread extends Thread
{
	// 模拟用户账户
	private Account account;
	// 当前取钱线程所希望取的钱数
	private double drawAmount;
	public DrawThread(String name, Account account, double drawAmount)
	{
		super(name);
		this.account = account;
		this.drawAmount = drawAmount;
	}
	// 当多条线程修改同一个共享数据时,将涉及数据安全问题。
	public void run()
	{
		// 使用account作为同步监视器,任何线程进入下面同步代码块之前,
		// 必须先获得对account账户的锁定——其他线程无法获得锁,也就无法修改它
		// 这种做法符合:“加锁 → 修改 → 释放锁”的逻辑
		synchronized (account)
		{
			// 账户余额大于取钱数目
			if (account.getBalance() >= drawAmount)
			{
				// 吐出钞票
				System.out.println(getName() + "取钱成功!吐出钞票:" + drawAmount);
				try
				{
					Thread.sleep(1);
				}
				catch (InterruptedException ex)
				{
					ex.printStackTrace();
				}
				// 修改余额
				account.setBalance(account.getBalance() - drawAmount);
				System.out.println("\t余额为: " + account.getBalance());
			}
			else
			{
				System.out.println(getName() + "取钱失败!余额不足!");
			}
		}
		// 同步代码块结束,该线程释放同步锁
	}
}

使用synchronized将run()方法里的方法体修改成同步代码块,该同步代码块的同步监视器是account对象,这样的做法符合“加锁–》修改–》释放锁”的逻辑,通过这样的方式保证并发线程在任何一个时刻只有一个线程可以进入修改共享资源的代码区也称为临界区,同一时刻只有一个线程处于临界区

同步方法

除了同步代码块,Java还提供了同步方法来解决多线程的安全问题,同步方法就是使用synchronized关键字来修饰某个方法,则该方法成为同步方法
对于synchronized关键字修饰的实例方法(非static方法)而言,无需显示的指定同步监视器,同步方法的同步监视器是this,也就是调用该方法的对象

public class Account
{
	// 封装账户编号、账户余额两个成员变量
	private String accountNo;
	private double balance;
	public Account(){}
	// 构造器
	public Account(String accountNo, double balance)
	{
		this.accountNo = accountNo;
		this.balance = balance;
	}

	// accountNo的setter和getter方法
	public void setAccountNo(String accountNo)
	{
		this.accountNo = accountNo;
	}
	public String getAccountNo()
	{
		return this.accountNo;
	}
	// 因此账户余额不允许随便修改,所以只为balance提供getter方法,
	public double getBalance()
	{
		return this.balance;
	}

	// 提供一个线程安全draw()方法来完成取钱操作
	public synchronized void draw(double drawAmount)
	{
		// 账户余额大于取钱数目
		if (balance >= drawAmount)
		{
			// 吐出钞票
			System.out.println(Thread.currentThread().getName() + "取钱成功!吐出钞票:" + drawAmount);
			try
			{
				Thread.sleep(1);
			}
			catch (InterruptedException ex)
			{
				ex.printStackTrace();
			}
			// 修改余额
			balance -= drawAmount;
			System.out.println("\t余额为: " + balance);
		}
		else
		{
			System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足!");
		}
	}

	// 下面两个方法根据accountNo来重写hashCode()和equals()方法
	public int hashCode()
	{
		return accountNo.hashCode();
	}
	public boolean equals(Object obj)
	{
		if (this == obj)
			return true;
		if (obj != null && obj.getClass() == Account.class)
		{
			var target = (Account) obj;
			return target.getAccountNo().equals(accountNo);
		}
		return false;
	}
}

修改了Account的代码,增加了一个取钱的draw()方法,并使用了synchronized关键字修饰该方法,把该方法作为同步方法,其同步监视器是this,对于同一个Account账户而言,任意时刻只能有一个线程获得对Account对象的锁定,然后进入draw()方法执行

对应修改DrawThread代码

public class DrawThread extends Thread
{
	// 模拟用户账户
	private Account account;
	// 当前取钱线程所希望取的钱数
	private double drawAmount;
	public DrawThread(String name, Account account,
		double drawAmount)
	{
		super(name);
		this.account = account;
		this.drawAmount = drawAmount;
	}
	// 当多条线程修改同一个共享数据时,将涉及数据安全问题。
	public void run()
	{
		// 直接调用account对象的draw方法来执行取钱
		// 同步方法的同步监视器是this,this代表调用draw()方法的对象。
		// 也就是说:线程进入draw()方法之前,必须先对account对象的加锁。
		account.draw(drawAmount);
	}
}

已经使用synchronized关键字修饰了draw()方法,同步方法的同步监视器是this,而this总代表调用该方法的对象,在这个示例里this就代表account,因此多个线程并发修改同一份account之前,必须先对account对象加锁,这也是“加锁–》修改–》释放锁”的逻辑

不可变类是线程安全的,因为他的对象状态是不可改变的;可变类的线程安全是以降低程序的运行效率作为代价的,为了减少线程安全所带来的负面影响,程序可以采用如下策略:

  • 不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源关系的的方法进行同步,例如Account类的accountNo实例变量就无需同步,只需要对draw()方法进行同步
  • 如果可变类有两种运行环境:单线程环境和多线程环境,则该可变类应提供两个版本,即线程不安全版本和线程安全版本,例如JDK提供的StringBuilder、和StringBuffer就是这种情景,单线程环境应该使用StringBuilder,多线程环境应该使用StringBuffer

释放同步监视器的锁定

任何线程进入同步代码块、同步方法前都要对同步监视器进行锁定,但并没有显示的释放对同步监视器的锁定,在如下几种情况下会释放同步监视器的锁定:

  • 当前线程的同步方法、同步代码块执行结束,当前线程即释放同步监视器
  • 当前线程在同步代码块、同步方法中遇到break、return终止了该代码块、该方法的继续执行,当前线程会释放同步监视器
  • 当前线程在同步代码块、同步方法中出现了未处理的Error或Exception导致了该代码块、该方法异常结束时,当前线程将会释放同步监视器
  • 当前线程执行同步代码块或同步方法时,程序执行了同步监视器对象的wait()方法,则当前线程暂停,并释放同步监视器
    而如下几种情况不会释放同步监视器:
  • 线程执行同步代码块或同步方法时,程序调用Thread.sleep()、Thread.yield()方法来暂停当前线程的执行,当前线程不会释放同步监视器
  • 线程执行同步代码块或同步方法时,,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器,当然应该尽量避免使用suspend()和resume()方法来控制线程

同步锁LOCK

Java5提供了更强大的线程同步机制,通过显示的定义同步锁Lock对象来实现同步,Lock提供了比synchronized方法和synchronized代码块更广发的锁定操作,它是控制多线程对共享资源进行访问的工具
锁,提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象

  • 有些锁允许对共享资源并发访问,例如ReadWriteLock(读写锁)
  • Java5为Lock接口提供了ReentrantLock(可重入锁)实现类,为ReadWriteLock提供了ReentrantReadWriteLock实现类
  • Java8还新增了StampedLock类
  • ReentrantReadWriteLock为读写操作提供了三种锁模式,Writing、ReadingOptimistic、Reading
    比较常用的是ReentrantLock,使用该Lock对象可以显示的加锁、释放锁
class X
{
	// 定义锁对象
	private final ReentrantLock lock = new ReentrantLock();
	// 定义需要保证线程安全的方法
	public void m()
	{
		// 加锁
		lock.lock();
		try
		{
			//需要保证线程安全的代码
			...
		}	
		// 使用finally块来保证释放锁
		finally
		{
			lock.unlock();
		}
	}
}

使用ReentrantLock对象来进行同步,加锁和释放锁出现在不同的作用范围内,一般情况下使用finally块来确保在必要时释放锁

import java.util.concurrent.locks.*;

public class Account
{
	// 定义锁对象
	private final ReentrantLock lock = new ReentrantLock();
	// 封装账户编号、账户余额的两个成员变量
	private String accountNo;
	private double balance;
	public Account(){}
	// 构造器
	public Account(String accountNo, double balance)
	{
		this.accountNo = accountNo;
		this.balance = balance;
	}

	// accountNo的setter和getter方法
	public void setAccountNo(String accountNo)
	{
		this.accountNo = accountNo;
	}
	public String getAccountNo()
	{
		return this.accountNo;
	}
	// 因此账户余额不允许随便修改,所以只为balance提供getter方法,
	public double getBalance()
	{
		return this.balance;
	}

	// 提供一个线程安全draw()方法来完成取钱操作
	public void draw(double drawAmount)
	{
		// 加锁
		lock.lock();
		try
		{
			// 账户余额大于取钱数目
			if (balance >= drawAmount)
			{
				// 吐出钞票
				System.out.println(Thread.currentThread().getName() + "取钱成功!吐出钞票:" + drawAmount);
				try
				{
					Thread.sleep(1);
				}
				catch (InterruptedException ex)
				{
					ex.printStackTrace();
				}
				// 修改余额
				balance -= drawAmount;
				System.out.println("\t余额为: " + balance);
			}
			else
			{
				System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足!");
			}
		}
		finally
		{
			// 修改完成,释放锁
			lock.unlock();
		}
	}

	// 下面两个方法根据accountNo来重写hashCode()和equals()方法
	public int hashCode()
	{
		return accountNo.hashCode();
	}
	public boolean equals(Object obj)
	{
		if (this == obj)
			return true;
		if (obj != null && obj.getClass() == Account.class)
		{
			var target = (Account) obj;
			return target.getAccountNo().equals(accountNo);
		}
		return false;
	}
}
  • 同步方法和同步代码块使用与竞争资源相关的、隐式的同步监视器,并且强制要求加锁和释放锁要出现在一个块结构中,并且当获取了多个锁时,他们必须以相反的顺序释放,且必须在与所有锁被获取时相同的范围内释放所有锁
  • Lock提供了同步方法和同步代码块没有的功能,包括用于非块结构的tryLock()方法,以及试图获取可中断锁的lockInterruptibly()方法,还有获取超时失效锁的tryLock(long, TimeUnit)方法
  • ReentrantLock锁具有可重入性,也就是说一个线程可以对已被枷锁的ReentrantLock锁再次加锁,ReentrantLock对象会维持一个计数器来追踪lock()方法的嵌套调用,线程在每次调用lock()加锁后,必须显示调用unlock()来释放锁,所以一段被锁保护的代码可以调用另一个被相同锁保护的方法

死锁

当两个线程相互等待对方释放同步监视器时就会发生死锁,Java虚拟机没有监测也没有采取措施来处理死锁,多线程编程时应采取措施避免死锁,一旦死锁整个程序不会发生任何异常,也不会出现提示,只是所有线程处于阻塞状态,无法继续

class A
{
	public synchronized void foo(B b)
	{
		System.out.println("当前线程名: " + Thread.currentThread().getName() + " 进入了A实例的foo()方法" );     
		try
		{
			Thread.sleep(200);
		}
		catch (InterruptedException ex)
		{
			ex.printStackTrace();
		}
		System.out.println("当前线程名: " + Thread.currentThread().getName() + " 企图调用B实例的last()方法");    
		b.last();
	}
	public synchronized void last()
	{
		System.out.println("进入了A类的last()方法内部");
	}
}
class B
{
	public synchronized void bar(A a)
	{
		System.out.println("当前线程名: " + Thread.currentThread().getName() + " 进入了B实例的bar()方法" );   
		try
		{
			Thread.sleep(200);
		}
		catch (InterruptedException ex)
		{
			ex.printStackTrace();
		}
		System.out.println("当前线程名: " + Thread.currentThread().getName() + " 企图调用A实例的last()方法");  
		a.last();
	}
	public synchronized void last()
	{
		System.out.println("进入了B类的last()方法内部");
	}
}
public class DeadLock implements Runnable
{
	A a = new A();
	B b = new B();
	public void init()
	{
		Thread.currentThread().setName("主线程");
		// 调用a对象的foo方法
		a.foo(b);
		System.out.println("进入了主线程之后");
	}
	public void run()
	{
		Thread.currentThread().setName("副线程");
		// 调用b对象的bar方法
		b.bar(a);
		System.out.println("进入了副线程之后");
	}
	public static void main(String[] args)
	{
		var dl = new DeadLock();
		// 以dl为target启动新线程
		new Thread(dl).start();
		// 调用init()方法
		dl.init();
	}
}
  • 程序无法执行完毕,也不会抛出异常,程序中A对象和B对象的方法都是同步方法,也就是A对象和B对象都是同步锁
  • 程序中有两个线程主线程的线程执行体是DeadLock的mian()方法,副线程的线程执行体是DeadLock类的run()方法
  • run()方法让B对象调用bar()方法,而init()方法让A对象调用foo()方法,主线程先执行init()方法,调用了A对象的foo()方法,进入foo()方法之前,该线程对A对象加锁,然后主线程暂停200ms;
  • CPU切换到另一个线程执行,让B对象执行bar()方法,副线程开始执行B对象的bar()方法之前,该线程对B对象加锁,然后也暂停200ms
  • 然后主线程醒过来,继续执行,当它调用B对象的last()方法的时候需要给B对象加锁,但是此时副线程正持有B对象的锁,所以主线程阻塞
  • 然后副线程醒过来,继续执行,当他调用A对象的last()方法的时候需要给B对象加锁,但是此时主线程持有A对象的锁
  • 主线程持有A对象的锁,等待对B对象加锁;副线程只有B对象的锁,等待对A对象加锁,两个线程互相等待对方释放锁,就产生了死锁
    Thread类的suspend()方法也很容易导致死锁,也不建议使用该方法来暂停线程的执行
发布了208 篇原创文章 · 获赞 131 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/dawei_yang000000/article/details/105440373