【Java】并发的这些基础你都了解吗?(part 1)


一、多进程与多线程的概念

多进程(多任务):操作系统的一种能力,可以让系统看起来能够在同一时刻运行多个程序。实际上,操作系统只是为每一个进程分配CPU时间片,多个进程交替占用CPU,由于时间片非常非常短,因此看起来像是在同时运行,实际上在单个CPU上还是串行的。
多线程:一个进程可以分成多个线程(子任务),这些线程可以同时在多个CPU上运行,或者一个CPU依次运行多个线程,这样能大大提高本程序的运行效率。
区别

  • 线程更轻量、更易转换
  • 线程通讯更高效
  • 进程拥有自己完整的一套变量,而线程共享数据

二、线程的状态

  • New(新建)
  • Runnable(可运行):可运行状态只是相对其他状态而言的,一个可运行状态的线程既可能正在运行,也可能永远未运行。
  • Blocked(阻塞)& Waiting(等待):线程进入这两种非活动状态的原因有两点:a)未获得内部的对象锁。b)线程因使用wait方法、等待Condition等进入了等待状态。
  • Timed waiting(计时等待):线程可以调用一些带有超时参数的方法进入计时等待状态,一旦接收到适当的通知或者达到规定时间,线程又会被重新激活。
  • Terminated(终止):线程的终止只有两种情况:a)run方法正常退出,线程自然终止。b)因为出现了一个没有捕获的异常终止了run方法,线程意外终止。(这里不考虑已经被废弃的方法。)

三、用Thread & Runnable实现多线程

1、示例

假定你创建了一个Bank类,里面存储了账户数目以及每个账户的初始金额,并实现了一个transfer方法,将一个账户的金额转移到另一个账户:

import java.util.*;
//实现银行类
public class Bank
{
   private final double[] accounts;
   public Bank(int n, double initialBalance)
   {
      accounts = new double[n];
      Arrays.fill(accounts, initialBalance);
   }
   public void transfer(int from, int to, double amount)
   {
      if(accounts[from] < amount) return;
      System.out.print(Thread.currentThread());
      accounts[from] -= amount;
      System.out.printf(" %10.2f from %d to %d", amount, from, to);
      accounts[to] += amount;
      System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
   }
   public double getTotalBalance()
   {
      double sum = 0;
      for(double a : accounts)
      {
         sum += a;
      }
      return sum;
   }
}

现在,账户0需要转十次钱给账户1,账户2也需要同时转十次钱给账户3,每次转移若干金额。你该怎样用计算机来完成这个过程?

首先,我们需要定义线程:继承Thread类 / 实现Runnable接口。无论使用哪一种方式,都需要实现run()方法:

public interface Runnable //Runnable接口
{
   void run();
}
class MyThread1 extends Thread//使用继承来定义线程
{
   public static final int DELAY = 10;
   public static fianl int STEPS = 100;
   public static fianl double MAX_AMOUNT = 1000;
   
   public void run()
   {
      try
      {
         for(int i = 0; i < STEPS; i++)
         {
            double amount = MAX_AMOUNT * Math.random();
            bank.transfer(0, 1, amount);
            Thread.sleep((int)(DELAY*Math.random());
         }
      }
      catch(InterruptedException e){};
   }
}

class MyThread2 implements Runnable//使用Runnable接口来定义线程
{
   public static final int DELAY = 10;
   public static final int STEPS = 100;
   public static final double MAX_AMOUNT = 1000;
   
   public void run()
   {
      try
      {
         for(int i = 0; i < STEPS; i++)
         {
            double amount = MAX_AMOUNT * Math.random();
            bank.transfer(1, 2, amount);
            Thread.sleep((int)(DELAY*Math.random());
         }
      }
      catch(InterruptedException e){};
   }
}

接着,我们需要构造一个Thread对象

Thread t1 = new MyThread1();//使用继承的情况
Thread t2 = new Thread(new MyThread2());//使用接口的情况

最后,我们启动线程

t1.start();
t2.start();

完整代码:

import java.util.*;


public class ThreadTest1 {
	public static void main(String[] args)
	{
		var bank = new Bank(4, 1000);
		Thread t1 = new MyThread1(bank);
		Thread t2 = new Thread(new MyThread2(bank));
		t1.start();
		t2.start();
	}
}

class MyThread1 extends Thread//使用继承来定义线程
{
   public static final int DELAY = 10;
   public static final int STEPS = 100;
   public static final double MAX_AMOUNT = 1000;
   private Bank bank;
   public MyThread1(Bank bank)
   {
	   this.bank = bank;
   }
   
   public void run()
   {
      try
      {
         for(int i = 0; i < STEPS; i++)
         {
            double amount = MAX_AMOUNT * Math.random();
            bank.transfer(0, 1, amount);
            Thread.sleep((int)(DELAY*Math.random()));
         }
      }
      catch(InterruptedException e){};
   }
}

class MyThread2 implements Runnable//使用Runnable接口来定义线程
{
   public static final int DELAY = 10;
   public static final int STEPS = 100;
   public static final double MAX_AMOUNT = 1000;
   private Bank bank;
   public MyThread2(Bank bank)
   {
	   this.bank = bank;
   }
   public void run()
   {
      try
      {
         for(int i = 0; i < STEPS; i++)
         {
            double amount = MAX_AMOUNT * Math.random();
            bank.transfer(1, 2, amount);
            Thread.sleep((int)(DELAY*Math.random()));
         }
      }
      catch(InterruptedException e){};
   }
}

class Bank
{
   private final double[] accounts;
   public Bank(int n, double initialBalance)
   {
      accounts = new double[n];
      Arrays.fill(accounts, initialBalance);
   }
   public void transfer(int from, int to, double amount)
   {
      if(accounts[from] < amount) return;
      System.out.print(Thread.currentThread());
      accounts[from] -= amount;
      System.out.printf(" %10.2f from %d to %d", amount, from, to);
      accounts[to] += amount;
      System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
   }
   public double getTotalBalance()
   {
      double sum = 0;
      for(double a : accounts)
      {
         sum += a;
      }
      return sum;
   }
}

输出结果:
输出结果
可以看见,
线程0与线程1的执行顺序是随机的,这能充分说明,二者在并发运行。

请注意:

  • 尽管使用继承方式生成的对象启动更为简洁,但一般不使用这种方式,因为Java不支持多重继承,不要占用继承名额。
  • 整个程序中,我们都没有显式调用任何run方法,这是因为,当我们在调用start方法时,它会自动调用run方法。
  • 关于start方法,一旦调用立刻返回,因此才有了并发运行的情况。
  • 不用轻易显式调用run方法!直接调用run方法只会在一个线程中执行这个任务,并不会立刻返回,因此也不会并发运行。

2、interrupt方法

上文提到,

线程的终止只有两种情况:a)run方法正常退出,线程自然终止。b)因为出现了一个没有捕获的异常终止了run方法,线程意外终止。(这里不考虑已经被废弃的方法。)

不过,对一个线程使用interrupt方法可以请求终止这个线程。
对于每一个线程,都有一个boolean标志,可以用这个标志来判断是否线程被中断。

while(!Thread.currentThread().isInterrupted() && ...)//检查线程是否被中断

请注意:

  • 当线程处于阻塞或等待状态时(sleep / wait 等)被中断,将会抛出InterruptedException异常。(有些阻塞不能被中断)
  • 没有任何规定要求被请求终止的线程应当终止。被请求终止的线程甚至可以完全忽略该请求。
  • 如果一个run方法中每次工作迭代后都调用sleep方法(或者其他可中断的阻塞线程的方法),则不必要也没任何用处来使用isInterrupted检测是否被中断。取而代之的应该是捕获InterruptedException异常的语句。
try
{
   ...
   while(...)
   {
      ...
         Thread.sleep(delay);
   }
}
catch(InterruptedException e)
{
   ...
}
finally
{
   ...
}
  • 如果程序抛出了InterruptedException异常,它的清楚状态将会被解除!因此,在catch块中,我们常常会重新设置中断状态,方便调用者检查;或者,你可以选择只声明该异常,捕获直接交由调用者处理。
//形式一:
...
catch(InterruptedException e)
{
   Thread.currentThread().interrupt();
}
...
//形式二
void mySubTask() throws InterruptedException
  • run方法不允许抛出任何异常(即不允许使用throws),因为run方法是再调用start方法后jvm调用的,Java肯定不允许异常抛给jvm的,所以任何异常都应在逃逸至run方法前被捕获

3、守护线程

可以使用setDaemon方法将一个线程设置成为守护线程。

Thread t3 = new Thread(new MyThread2());
t3.setDaemon(true);

守护线程的作用:

  • 为其他线程提供服务

守护线程的特点:

  • 普通线程的结束是run方法运行结束(此时main可能早已结束)
  • 守护线程的结束是run方法运行结束,或者main函数结束。

四、竞态条件

介绍竞态条件之前,我们先来了解一下非原子性语句(如i++)的操作流程:

  1. 将主存中的数据读取到工作缓存(寄存器)中
  2. 在寄存器中执行相应的操作得到结果
  3. 将结果写回主存中

前面提到过,

多线程与多进程的最大区别在于,多线程能共享数据。

现在试想一下这样一个场景,如果一个线程正在执行一个非原子性操作(假定为i++操作),当它执行到2未执行3时,另一个线程也开始执行这个操作,由于前一个线程的结果并未更新到主存,该线程存入缓存中的仍为原来的i的值,最终,后一个线程的结果 i+1将覆盖前一个线程的结果,并更新主存的值为 i+1,而实际上你期望得到的结果是 i+2。

定义:在多线程应用中,两个或两个以上的线程需要存取同一个对象,并且每个线程分别调用了一个修改该对象的方法,导致线程间相互覆盖的情况被称为竞态条件

下面举一个更为详细的例子,在上面的银行类中,我们让所有账户都随机给另一个账户进行转账,每次都执行getTotal函数计算Bank中的存款总额。稍后你会发现惊奇的事情:

package unsynch;
import java.util.*;

public class UnsynchBankTest {
	public static final int NACCOUNTS = 100;
	public static final int DELAY = 10;
	public static final double INITIAL_BANLANCE = 1000;
	public static final double MAX_AMOUNT = 1000;
	
	public static void main(String[] args)
	{
		var bank = new Bank(NACCOUNTS, INITIAL_BANLANCE);
		for(int i = 0; i<NACCOUNTS; i++) {
			int fromAccount = i;
			Runnable r = ()->{
				try 
				{
					while(true)
					{
						int toAccount = (int)(NACCOUNTS * Math.random());
						double amount = MAX_AMOUNT * Math.random();
						bank.transfer(fromAccount, toAccount, amount);
						Thread.sleep((int)(DELAY * Math.random()));
					}
				}
				catch(InterruptedException e)
				{
					Thread.currentThread().interrupt();
				}
			};
			var t = new Thread(r);
			t.start();
		}
	}
}

输出结果中的一小部分:
输出结果二
你会发现,多个账户之间相互转账,存款总额竟然减少了!

原因
transfer方法不是原子操作,在一个线程执行transfer操作时可能会被另一个线程打断,一个线程可能会覆盖了另一个线程所做的更新,使得总金额不再正确。

解决思路

  1. 确保该方法一次只能由一个线程调用。
  2. 确保一个变量改变时,其他线程可以看见这个改变。

五、使用重入锁(ReentrantLock)对象实现同步

ReentrantLock类能够设置一个锁对象,一旦一个线程使用lock方法将锁对象锁定,其他任何线程都无法通过lock语句。它们会暂停直至锁对象解锁(unlock)。

public class Bank
{
   private ReentrantLock bankLock = new ReentrantLock();//创建一个锁对象
   ...
   public void transfer(int from, int to, int amount)
   {
      bankLock.lock();//上锁
      try
      {
         System.out.print(Thread.currentThread());
         accounts[from] -= amount;
         System.out.printf(" %10.2f from %d to %d", amount, from, to);
         accounts[to] += amount;
         System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
      }
      finally
      {
         bankLock.unlock();//解锁
      }
   }
}

请注意:

  • unlock 语句一定要放置在 finally 语句中,确保在临界区(lock-unlock之间)的代码能够释放锁,否则其他线程将永久堵塞
  • ReentrantLock锁是一把重入锁,线程可以反复获得已经拥有的锁。被锁保护的代码可以调用另一个使用了相同锁的方法(当然也可以调用使用不同锁的方法)。
  • ReentrantLock对象可以调用一个getHoldCount方法来获取已经拥有的锁的数量。譬如:假定getTotal函数也使用了bankLock锁,当transfer方法调用getTotal方法时,HoldCount的值变为2,退出时又变为1。

六、与锁相关联的条件对象(条件变量)

现在,我们希望对transfer方法进行优化:如果账户余额小于转账金额,这个线程的锁应当被释放,并且重新进入等待状态,直至被唤醒,再次尝试转账操作。

具体操作如下:

public class Bank
{
   private Condition sufficientFunds;//创建一个条件变量
   ...
   public Bank()
   {
      ...
      sufficientFunds = bankLock.newCondition();//将条件变量与锁对象相关联
   }
   public void transfer(int from, int to, int amount)
   {
      bankLock.lock();//上锁
      try
      {
         while(account[from] < amount)
         {
            sufficientFunds.await();//将该线程放置在这个条件对象的等待集中
         }
         ...
         sufficientFunds.signalAll();//解除条件对象的等待集中所有线程的阻塞状态
      }
      finally
      {
         bankLock.unlock();//解锁
      }
   }
}

请注意:

  • 一个线程从阻塞状态到达活动状态需要同时满足两个条件:获得了锁 & 不在条件对象的等待集中。
  • await方法使得线程暂停,并会放弃锁
  • 线程调用await方法后没有任何办法能够自行激活,只能依靠其他线程的signalAll / signal方法。
  • 除 void signalAll()方法外,还存在一个signal()方法,它的作用是随机解除一个线程的阻塞状态。

七、synchronized关键字

synchronized关键字与ReentranceLock对象实现同步的机制非常像,只不过synchronized使用的是内部锁。Java中每一个对象都有一个内部锁,如果一个方法声明时有synchronized关键字,那么对象的内部锁将会保护整个方法。
改写六中的transfer方法:

public class Bank
{
   ...
   public synchronized void transfer(int from, int to, int amount) throws InterruptedException
   {
      while(account[from] < amount)
         {
            wait();
         }
         ...
         notifyAll();
   }
}

这种方式获取的锁是本对象的内部锁。

或者使用同步块:

private Object lock = new Object();

public void transfer(int from, int to, double amount)
   {
      synchronized(lock)
      {
         while(account[from] < amount)
         {
            wait();
         }
         ...
         notifyAll();
      }
   }

这种方式获取的锁是对象 lock 的内部锁。

请注意
wait、notifyAll、notify方法是Object类中的final方法。而await、signalAll、signal方法为Condition类中的方法。

八、volatile关键字

上文提到,实现同步的两种解决思路:

解决思路

  1. 确保该方法一次只能由一个线程调用。
  2. 一个变量改变时,其他线程可以看见这个改变。

synchronized关键字与ReentranceLock是思路一的具体实现,而volatile是思路二的体现。它的作用是:如果声明一个字段为volatile,那么编译器和虚拟机就知道该字段可能被多个线程并发更新。编译器会插入适当代码,以确保一个线程对该字段作出修改,其他线程能够看见这个改动。

举例:

private volatile boolean done;
public boolean isDone(){return done;}
public void setDone(){done = true;}

一旦一个线程调用setDone方法修改done值为 true,其他线程能够立刻看见这个改动,isDone方法的返回值会变为true。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值