Java系列学习笔记 --- 线程(四)线程控制:join线程、后台线程、线程睡眠、同步线程等

目录

四、线程控制

       4.1 join线程

五、后台线程

六、线程睡眠sleep

七、线程让步yield

八、改变线程优先级

九、线程同步

       9.1 线程安全问题

       9.2 同步代码块

       9.3 同步方法

       9.4 释放同步监视器的锁定

       9.5 同步锁(Lock)

       9.6 死锁


四、线程控制

       Java提供一些便捷的方法可以很好地控制线程的执行。

4.1 join线程

       Thread提供了让一个线程等待另一个线程完成的方法 -- join()方法。当在某个程序执行流中调用其他线程的join()方法时,调用线程将被阻塞,直到被join()方法加入的join线程执行完为止。

       案例代码如下所示:

public class ThreadJoin extends Thread{
	//提供一个有参数的构造器,用于设置该线程的名字
	public ThreadJoin(String name){
		super(name);
	}
	public static void main(String[] args) throws Exception{
		new ThreadJoin("新线程").start();
		for(int i=0; i<20; i++){
			if(i == 5){
				ThreadJoin tj = new ThreadJoin("被Join的线程");
				tj.start();
				//main线程调用了jt线程的join()方法,main线程
				//必须等tj执行借宿之后才会向下执行
				tj.join();
			}
			System.out.println(Thread.currentThread().getName() + " " + i);
		}
	}
	//重写run()方法,定义线程执行体
	public void run(){
		for(int i=0;i<20;i++){
			System.out.println(this.getName() + " " + i);
		}
	}
}

       最终结果如下所示:

Java系列学习笔记 --- 线程(四)线程控制:join线程、后台线程、线程睡眠、同步线程等_第1张图片

       起初,main线程会和“新线程”的子进程并发执行。当主线程的循环变量i等于5时,启动了名为“被Join的线程”,main线程处于等待状态,只有等待该线程结束之后main线程才可以向下执行。

       join()方法有如下三种重载形式(第三种就不介绍了)。

  1. join():等待被join的线程执行完成。
  2. 等待被join的线程的时间最长为mills毫秒。如果在指定时间内,被join的线程还没有执行结束,则不再等待。

五、后台线程

       后台线程又称“守护线程”或“精灵线程”,它是在后台运行为其他的线程提供服务。JVM的垃圾回收线程就是典型的后台线程。

       后台线程有个特征:如果所有前台线程都死亡,后台线程会自动死亡。

       调用Thread对象的setDeamon(true)方法可以将指定线程设置成后台线程。下面的案例将执行线程设置成后台线程。

public class DeamonThread extends Thread{
	public static void main(String[] args) {
		DeamonThread dt = new DeamonThread();
		dt.setDaemon(true); //将此线程设置为后台线程
		dt.start();
		for(int i=1; i<=50; i++){
			System.out.println(Thread.currentThread().getName() + i);
		}
	}
	public void run(){
		for(int i=1; i<=100; i++){
			System.out.println(this.getName() + " " + i);
		}
	}
}

       最终结果如下图所示

Java系列学习笔记 --- 线程(四)线程控制:join线程、后台线程、线程睡眠、同步线程等_第2张图片

       从上面的结果中可以看到,一般的线程应该循环到100之后结束线程,但是将该线程设置为后台线程之后,当所有的线程都结束运行之后,随着JVM的主动退出,后台线程无论有没有执行完毕都要结束线程。

       Thread类还提供了isDeamon()方法用来判断指定线程是否为后台线程。

       需要注意的是,并不是所有的线程默认都是前台线程——前台线程创建的子线程默认是前台线程,后台线程创建的子线程默认是后台线程。前台线程死亡后,JVM会通知后台线程死亡,但从它接收指令到做出响应,需要一定的时间。而且要将某个线程设置为后台线程,必须在该线程启动之前设置,否则会引发异常。

六、线程睡眠sleep

       如果需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,可以通过调用Thread类的静态sleep()方法来实现,该方法有两种重载方式。

  1. static void sleep(long millis):让当前正在执行的线程暂停millis毫秒,并进入阻塞状态,该方法受到系统计时器和线程调度器的精度和准确度的影响。
  2. static void sleep(long millis , int nanos):让当前正在执行的线程暂停millis毫秒加nanos毫微妙,并进阻塞状态。

       当前线程调用sleep()方法进入阻塞状态后,在其睡眠时间段内,该线程不会获得执行的机会,即使系统中没有其他可执行的线程,处于sleep()中的线程也不会执行。下面实例通过唯一一个主线程来查看效果:

public class SleepThread {
	public static void main(String[] args) throws Exception {
		for(int i=1;i<=20;i++){
			System.out.println("当前时间:"+new Date());
			Thread.sleep(1000);
		}
	}
}

       最终的效果图如下所示

Java系列学习笔记 --- 线程(四)线程控制:join线程、后台线程、线程睡眠、同步线程等_第3张图片

       可以从中看到,上面程序中只存在主线程,主线程一秒的睡眠时间里,虽然没有其他线程的执行,但是主线程还是得老老实实的每过一秒就输出日期。

七、线程让步yield

       yield()方法是一个和sleep()方法有点像是的方法,它也是Thread类提供的一个静态方法,它也可以让当前正在执行的线程暂停,但是它不会阻塞当前执行的线程,只是将该线程转入就绪状态,让系统的线程调度器重新调度一次。

public class YieldThread extends Thread{
	public YieldThread(String name){
		super(name);
	}
	public static void main(String[] args) {
		YieldThread YT1 = new YieldThread("Hight");
		YT1.setPriority(Thread.MAX_PRIORITY);
		YT1.start();
		YieldThread YT2 = new YieldThread("Low");
		YT2.setPriority(Thread.MIN_PRIORITY);
		YT2.start();
	}
	public void run(){
		for(int i=1;i<=20;i++){
			System.out.println(getName() + " " + i);
			//当i等于10的时候,当前线程会让步给其他优先级等级的线程
			if(i == 10){
				Thread.yield();
			}
		}
	}
}

       需要注意的是,在多CPU并行处理的环境下,yield()方法的作用并不明显。

       关于sleep()方法和yield()方法的区别如下:

  1. sleep()方法暂停当前线程后,会给其他线程执行机会,不会理会其他线程的优先级;但是yield()方法只会给优先级相同或者优先级更高的线程执行机会。
  2. sleep()方法会将线程转入阻塞状态,直到过了阻塞时间才会转入就绪状态;而yield()不会将线程转入阻塞状态,它只是强制当前线程进入就绪状态。所以,调用yield()方法的进程有可能暂停之后,立即再次获得处理器的使用权限。
  3. sleep()方法声明抛出了InterruptedException异常,所以调用sleep()方法时要么捕获该异常,要么显示声明抛出该异常;而yield()方法则没有声明抛出任何异常。
  4. sleep()方法比yield()方法有更好的一致性,通常不建议使用yield()方法来控制并发线程的执行。

八、改变线程优先级

       每个线程执行时都具有一定的优先级,优先级高的线程获得较多的执行机会,而优先级低的线程则获得较少的执行机会。

       每个线程默认的优先级与创建它的父线程的优先级相同,默认情况下,main线程具有普通优先级。

       Thread类提供了setPriority(int newPriority)、getPriority()方法来设置和返回指定线程的优先级,其中设置优先级的参数可以是一个整数,范围是1~10之间,也可以使用Thread类的如下三个静态常量。

  1. MAX_PRIORITY:其值是10
  2. MIN_PRIORITY:其值是1
  3. NORM_PRIORITY:其值是5

       通过下面的例子,我们设定了一个高级线程和低级线程,从中观察高低线程的执行次数有何区别:

public class PriorityThread extends Thread{
	PriorityThread(String name){
		super(name);
	}
	public static void main(String[] args) {
		PriorityThread pr1 = new PriorityThread("Hight");
		pr1.setPriority(10);
		pr1.start();
		PriorityThread pr2 = new PriorityThread("Low");
		pr2.setPriority(1);
		pr2.start();
	}
	public void run(){
		for(int i=1;i<=20;i++){
			System.out.println(getName() + " " + i);
		}
	}
}

       结果如下图所示:

Java系列学习笔记 --- 线程(四)线程控制:join线程、后台线程、线程睡眠、同步线程等_第4张图片

       从上面的结果图中,可以看到优先级高的执行比低执行次数多。虽然Java提供了10个优先级,但是不同操作系统的优先级并不相同,例如Windows 2000仅仅提供了7个优先级,因此应该避免直接为线程指定优先级,应该使用三个静态常量MAX_PRIORITY、MIN_PRIORITY、NORM_PRIORITY来设置优先级。

九、线程同步

       多线程编程很容易出现“错误情况”,这是由系统的线程调度具有一定的随机性造成的,不过即使程序偶然出现问题,那也是由于编程不当引起的。

9.1 线程安全问题

       关于线程安全问题,有一个经典问题——银行取钱的问题。取钱的基本流程可以分为如下几个步骤。

  1. 用户输入账号、密码,系统判断用户的账户、密码是否匹配。
  2. 用户输入取款金额。
  3. 系统判断账户余额是否大于取款金额。
  4. 如果余额大于取款金额,则取款成功,反之则取款失败。

       如果一旦将上面这个流程放在多线程并发的场景下,就有可能出现问题。注意此处说的是有可能,并不是说一定。

       按照上面的流程去编写取款程序,使用两个线程来模拟取钱操作,模拟两个人使用同一个账户并发取钱的问题。此处忽略第一步,因为没有必要,仅仅模拟后面三步操作。下面,先定义一个账户类、该账户类封装了账户编号和余额两个实例变量。

  1. 首先我们先创建一个账户类,里面存储了账户的信息。
public class Account {
	//封装账户编号、账户余额的两个成员变量
	private String accountNo;
	private double balance;
	Account(String accountNo,double balance){
		this.accountNo = accountNo;
		this.balance = balance;
	}
	public int hashCoude(){
		return accountNo.hashCode();
	}
	public boolean equals(Object obj){
		if(this == obj){
			return true;
		}
		if(obj != null && obj.getClass() == Account.class){
			Account target = (Account)obj;
			return target.getAccountNo().equals(accountNo);
		}
		return false;
	}
//忽略Get和Set方法
}
  1. 然后我们再创建线程处理类,该线程用来处理取钱操作逻辑。
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);
			//修改余额
			account.setBalance(account.getBalance() - drawAmount);
			System.out.println("\t余额为:"+account.getBalance());
		}else{
			System.out.println(getName()+"取款失败!余额不足!");
		}
	}
}

      (3)最后我们创建主程序,仅仅是创建一个账户,并且新建两个线程对这个账户进行取钱操作。

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

       多次运行上面的程序,很很有可能会看到下面图示的错误结果。这种错误结果正是多线程编程突然出现的 “偶然”错误——因为线程调度的不确定性。

Java系列学习笔记 --- 线程(四)线程控制:join线程、后台线程、线程睡眠、同步线程等_第5张图片

9.2 同步代码块

       之所以出现上图所示的结果,是因为run()方法的方法体不具有同步安全性——程序中有两个并发线程在修改Account对象;而且恰好在处理的过程中执行线程切换,导致出现了问题。

       为了解决这个问题,Java的多线程引入了同步监视器来解决这个问题,使用同步监视器的方法就是同步代码块。其语法格式如下所示:

synchronized ( obj ){
    //内部代码就是同步代码块...
}

       上面语法格式中括号里的obj就是同步监视器,上面代码的含义是:线程开始执行同步代码块之前,必须先获得对同步监视器的锁定。

       【注意】任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完毕之后,该线程会释放对该同步监视器的锁定。

       虽然Java程序允许使用任何对象作为同步监视器,但是想一下同步监视器的目的:阻止两个线程对同一个共享资源进行并发访问,因此推荐使用可能被并发访问的对象充当同步监视器。

       接下来,我们将上面取钱的例子中的线程处理类的run()方法中的线程执行体修改成如下形式:

public void run(){
	//使用account作为同步监视器,任何线程进入下面同步代码块之前
	//必须先获得对account账户对象的锁定,其他线程无法获得锁,就无法修改它
	//这种做法符合:"加锁 -- 修改  -- 释放锁"
	synchronized(account){
		//账户余额 > 取钱金额
		if(account.getBalance() >= drawAmount){
			//吐出钞票
			System.out.println(getName()+"取钱成功!取钱:"+drawAmount);
			//修改余额
			account.setBalance(account.getBalance() - drawAmount);
			System.out.println("\t余额为:"+account.getBalance());
		}else{
			System.out.println(getName()+"取款失败!余额不足!");
		}
	}
}

       最终结果如下图所示。

Java系列学习笔记 --- 线程(四)线程控制:join线程、后台线程、线程睡眠、同步线程等_第6张图片

       这是因为上面程序使用synchronized将run()方法里的方法体修改成同步代码块,该同步代码块的同步监视器是account对象,任何线程在修改指定资源之前,首先对该资源加锁,在加锁期间其他线程无法修改该资源,当当前线程修改完成之后,该线程在释放对该资源的锁定,之后下一个线程对该同步代码块进行锁定,防止其他的线程对该资源进行操作。

       通过这种方式就可以保证并发线程在任一时刻只有一个线程可以进入修改共享资源的代码区(也被称为临界区),所以同一时刻最多只有一个线程处于临界区,从而保证了线程的安全。

       总结:线程在执行同步代码块时,会对共享资源进行加锁,防止其他线程的同步操作。当线程执行完毕之后释放对该共享资源的锁定,从而让下一个线程访问该共享资源并再一次进行锁定并处理。

9.3 同步方法

       同步方法的作用和同步代码块对于,它的使用方法时通过synchronized关键字来修饰某个方法,该方法就称为同步方法。同步方法无需显示指定同步监视器,同步方法的同步监视器是this,也就是调用该方法的对象。

       使用同步方法可以非常方便地实现线程安全的类,同步类具有如下特征:

  1. 该类的对象可以被多个线程安全地访问。
  2. 每个线程调用该对象的任意方法之后都将得到正确的结果。
  3. 每个线程调用该对象的任意方法之后,该对象状态依然保存合理状态。

       不可变类总是线程安全的,因为它的对象状态不可改变,但可变对象需要额外的方法来保证其线程安全。例如上面的Account就一个可变类,它的accountNo和balance两个成员变量都可以被改变。

       接下来,我们将上面例子修改为使用同步方法之后的程序范例如下:

  1. 账户类代码如下所示。
public class Account {
	private String accountNo; //账号编号
	private double balance;	//账户余额
	Account(String accountNo,double balance){
		this.accountNo = accountNo;
		this.balance = balance;
	}
	//取钱操作的同步方法
	public synchronized void draw(double drawAmount){
		//获取当前线程名
		String tName = Thread.currentThread().getName();
		//判断当前账户余额是否大于或等于取钱金额
		if(balance >= drawAmount){
			System.out.println(tName+"取钱成功!吐出钞票:"+drawAmount);
			balance -= drawAmount; //修改取钱之后的余额
			System.out.println("\t剩余金钱:"+balance);
		}else{
			System.out.println(tName + "取款失败!余额不足!");
		}
	}
//忽略Get和Set方法
}
  1. 线程类代码如下所示。
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);
	}
}

       主程序我们不改动,最总的结果如下图所示

Java系列学习笔记 --- 线程(四)线程控制:join线程、后台线程、线程睡眠、同步线程等_第7张图片

       需要注意的是,可变类的线程安全是以降低程序的运行效率为代价的,为了减少线程安全所带来的负面影响,程序可以采用如下策略:

  1. 不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源(共享资源)的方法进行同步。
  2. 如果可变类有两种运行环境:单线程环境和多线程环境,则应该为该可变类提供两种版本,即线程不安全版本和线程安全版本。

9.4 释放同步监视器的锁定

       任何线程进入同步代码块、同步方法之前,必须先获得对同步监视器的锁定,但是合适会释放对同步监视器的锁定呢?程序无法显示释放对同步监视器的锁定,线程会在如下几种请何况下释放对同步监视器的锁定

  1. 当前线程的同步方法、同步代码块执行结束,当前线程释放同步监视器。
  2. 当线程在同步代码块、同步方法中遇到break、return终止了该代码块、该方法的继续执行,当前线程将会释放同步监视器。
  3. 当前线程在同步代码块、同步方法中出现了未处理的Error或Exception,导致该法代码块、该方法异常借宿是,当前线程将会释放同步监视器。
  4. 当前线程执行同步代码块或同步方法时,程序执行了同步监视器对象的wait()方法时,暂停当前线程,并释放同步监视器。

       在如下所示的情况下,线程不会释放同步监视器:

  1. 线程执行同步代码块或同步方法时,程序调用Thread.sleep()、Thread.yield()方法来暂停当前线程的执行,当前线程不会释放同步监视器。
  2. 线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器。所以,程序应该尽量避免使用suspend()和resume()方法来控制线程。

       总结:线程执行结束和异常都会释放同步监视器;而线程的暂停和挂起则不会释放同步监视器。

9.5 同步锁(Lock)

       相比同步代码块和同步方法,Java还提供了一种功能更强大的线程同步机制——通过显示定义同步锁对象来实现同步。

       Lock是控制多个线程对共享资源进行访问的工具,它提供了比同步方法和同步代码块更广泛的锁定操作,能够支持多个相关的Condition对象。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。

       某些锁运行对共享资源并发访问,如ReadWriteLock(读写锁),Lock和ReadWriteLock是Java5提供的两个根接口,并非Lock提供了ReentrantLock(可重入锁)实现类,为ReadWriteLock提供了ReentrantReadWriteLock实现类。

       Java8新增了信息的StampedLock类,在大多数场景中可以代替传统的ReentrantReadWriteLock。ReentrantReadWriteLock为读写操作提供了三种锁模式:Writing、ReadingOptimistic、Reading。

       在实现线程安全的控制中,比较常用的ReentrantLock(可重入锁)。使用该Lock对象可以显示地加锁、释放锁。语法格式的范例代码如下所示。

import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo {
	private final ReentrantLock lock = new ReentrantLock();
	public void run(){
		lock.lock(); //【加锁】
		try{
			//共享资源代码块
		}finally{
			lock.unlock(); //【释放锁】
		}
	}
}

       使用Lock与使用同步方法有点相似,只是使用Lock时,显示使用Lock对象作为同步锁,而使用同步方法时系统隐式使用当前对象作为同步监视器。

       同步方法或同步代码块使用与竞争资源相关的、隐式的同步监视器,并且强制要求加锁和释放锁要出现在同一块结构中,而且获取了多个锁时,它们必须以相反顺序释放,且必须在与所有所被锁获取时相同的范围内释放所有锁。

       Lock锁相比同步方法和同步代码块,它的使用更加地灵活,提供了非块结构的tryLock()方法、以及试图获取可中断锁的lockInteruptibly()方法,还有获取超时失效的tryLock(long,TimeUnit)方法。

       ReentrantLock锁具有可重入性,即已被加锁的ReentrantLock锁可以再次加锁,线程每次调用lock()加锁之后,必须显示调用unlock()来释放锁。

9.6 死锁

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

       死锁是很容易发生的,尤其在系统中出现多个同步监视器的情况下,如下面的程序范例就会出现死锁。

public class DeadLock implements Runnable{
	A a = new A();
	B b = new B();
	public void init(){
		Thread.currentThread().setName("主线程");
		a.foo(b);
		System.out.println("进入了主线程之后");
	}
	public static void main(String[] args){
		DeadLock dl = new DeadLock();
		//以dl为target启动新线程
		new Thread(dl).start();
		//调用init()方法
		dl.init();
	}
	public void run(){
		Thread.currentThread().setName("副线程");
		b.bar(a);
		System.out.println("进入了副线程之后");
	}
}
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()+"进入了A实例的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()方法内部");
	}
}

       最后我们可以看到控制一直处于执行状态。

Java系列学习笔记 --- 线程(四)线程控制:join线程、后台线程、线程睡眠、同步线程等_第8张图片

 

你可能感兴趣的:(Java)