当两个或两个以上的线程需要共享资源,它们需要某种方法来确定资源在某一刻仅被一个线程占用,达到这个目的的过程叫做同步。如果线程在操作共享资源时没有实现同步,那么很有可能会出现数据处理错误的情况。例如13.6.4小节中举过一个例子:A线程负责向文件中写入“张小明”这个名字,B线程负责向文件中写入“王敏慧”这个名字,如果A和B同时操作文件,就有可能导致文件中的内容变成“张小王明敏慧”,这样文件的内容就变得“乱七八糟”。为了避免出现这种现象,就必须要求多个线程不能同时操作同一个对象。下面的【例14_08】展示了两个线程没有实现同步的情况下操作同一个对象时产生的效果。
【例14_08 线程不同步】
Exam14_08.java
- class Printer1{//打印机
- void print(String msg)
- {
- System.out.print("[" + msg);
- try{
- Thread.sleep(1000);
- } catch (InterruptedException e){
- System.out.println("Interrupted");
- }
- System.out.println("]");
- }
- }
-
- class Operator1 extends Thread{//打印员
- Printer1 p1;
- String msg;
- public Operator1(Printer1 p1, String msg){
- this.p1 = p1;
- this.msg = msg;
- }
- public void run() {
- p1.print(msg);
- }
- }
-
- public class Exam14_08 {
- public static void main(String[] args) {
- Printer1 p1 = new Printer1();
- Operator1 op1 = new Operator1(p1,"apple");
- Operator1 op2 = new Operator1(p1,"banana");
- Operator1 op3 = new Operator1(p1,"orange");
- op1.start();
- op2.start();
- op3.start();
- }
- }
【例14_08】的Exam14_08.java中Printer1类表示打印机,它的run()方法打印参数所指定的字符串,并在打印字符串时在其左右各加一个方括号。为演示出线程共用打印机的效果,每次打印右方括号前让线程睡眠1000毫秒。Operator1表示打印员,Operator1类有一个Printer1类型的属性p1,它表示打印员所使用的打印机,还有一个String类的属性msg,它表示打印员要打印的字符串。main()方法中创建了三个Operator1对象op1、op2和op3。从代码中可以看到:这三个对象打印不同的字符串,但共用一个打印机。【例14_08】的运行效果如图14-9所示。
图14-9【例14_08】运行效果
从图14-9可以看出:打印结果出现了混乱,这是因为第一个线程在没有用完打印机的情况下,由于自身进入睡眠状态,第二个线程就抢占了打印机并开始打印造成的。同样,第二个线程在没有用完打印机的情况下,第三个线程又抢占了打印机。为了避免打印出现混乱,应该保证一个线程在使用打印机的情况下任何其他线程都不能使用它,知道这个线程用完之后,其他线程才能再次使用。保证一个对象不能被多个线程同时使用的技术就是线程同步,本小节将讲解同步技术的实现以及同步相关的各种问题。
所谓“同步方法”就是被synchronized关键字所修饰的方法,如果类中定义了同步方法,那么同一个对象的同步方法不能同时被多个线程调用。例如:在A类中定义了method1()和method2()两个同步方法,那么当创建出A类对象a后,一个线程在调用a对象的method1()方法时,另一个线程不能同时调用a对象的method1()或method2()方法,只能等第一个线程结束调用method1()方法之后才能开始调用。在【例14_08】中,一个线程调用Printer1的print()方法时,另一个线程也对其进行调用,最终导致打印结果出现混乱。如果print()方法前面加上synchronized关键字就不会出现这样的情况,因为同步方法在一个线程调用结束前另一个线程无法调用这个方法。下面的【例14_09】展示了同步方法的运行效果。
【例14_09 同步方法】
Exam14_09.java
- class Printer2{//打印机
- synchronized void print(String msg)
- {
- System.out.print("[" + msg);
- try{
- Thread.sleep(1000);
- } catch (InterruptedException e){
- System.out.println("Interrupted");
- }
- System.out.println("]");
- }
- }
-
- class Operator2 extends Thread{//打印员
- Printer2 p2;
- String msg;
- public Operator2(Printer2 p2, String msg){
- this.p2 = p2;
- this.msg = msg;
- }
-
- public void run() {
- p2.print(msg);
- }
- }
-
- public class Exam14_09 {
- public static void main(String[] args) {
- Printer2 p2 = new Printer2();
- Operator2 op1 = new Operator2(p2,"apple");
- Operator2 op2 = new Operator2(p2,"banana");
- Operator2 op3 = new Operator2(p2,"orange");
- op1.start();
- op2.start();
- op3.start();
- }
- }
【例14_09】的Exam14_09.java文件中也定义了3个类,不难看出,这些类的定义与【例14_08】中所定义的那三个类非常相似,表示打印机的类变成了Printer2。Printer2与Printer1的区别仅是print()方法前面添加了synchronized的关键字。【例14_09】的运行结果如图14-10所示。
图14-10【例14_09】运行结果
从图14-10可以看出,使用同步方法打印字符串不会出现混乱的情况,此外打印的结果中先出现了“orange”,后出现“banana”,这说明线程的运行有一定的随机性,并不是先启动的线程就一定先运行。实际运行程序时,读者会看到在打印字符串过程中线程会出现暂停,但暂停时间内不会有另一个线程同时执行打印操作。
很多情况下,程序员无法修改某个方法使之成为同步方法,例如Java基础类库中的那些类的方法就不能被修改。在这种情况下,如果希望一个线程操作某个对象时另一个线程不能同时对这个对象进行操作,那么就需要用到同步代码块。同步代码块的作用是对对象进行锁定,实现同步代码块的也是用synchronized关键字,具体格式如下:
以上这个格式中,synchronized关键字后面的小括号内就是线程要操作的对象,当线程操作这个对象时,由于使用了synchronized的关键字对对象进行了锁定,所以其他线程无法在当前线程操作对象时操作这个对象。下面的【例14_10】展示了使用同步代码块锁定对象的运行效果。
【例14_10 同步代码块】
Exam14_10.java
- class Printer3{//打印机
- void print(String msg)
- {
- System.out.print("[" + msg);
- try{
- Thread.sleep(1000);
- } catch (InterruptedException e){
- System.out.println("Interrupted");
- }
- System.out.println("]");
- }
- }
-
- class Operator3 extends Thread{//打印员
- Printer3 p3;
- String msg;
- public Operator3(Printer3 p3, String msg){
- this.p3 = p3;
- this.msg = msg;
- }
-
- public void run() {
- synchronized (p3){//同步代码块
- p3.print(msg);
- }
- }
- }
-
- public class Exam14_10 {
- public static void main(String[] args) {
- Printer3 p3 = new Printer3();
- Operator3 op1 = new Operator3(p3,"apple");
- Operator3 op2 = new Operator3(p3,"banana");
- Operator3 op3 = new Operator3(p3,"orange");
- op1.start();
- op2.start();
- op3.start();
- }
- }
【例14_10】中,打印机类Printer3的print()方法并不是同步方法,但线程Operator3在调用Printer3类对象时使用synchronized关键字对其进行了锁定,所以在锁定期间,线程调用对象的任何一个方法过程中其他线程都不能同时调用这个对象的方法。【例14_10】的运行结果如图14-11所示。
图14-11【例14_10】运行结果
从图14-11可以很明显的看出:即使对象没有定义同步方法,只要线程在操作对象时使用同步代码块依然能够保证线程在调用该对象方法时其他线程不能同时操作对象。
实际上,无论用哪一种方式实现线程同步都会使得程序执行的效率有所下降,这是因为同步会导致对象从共用状态变为独占状态。为避免程序运行效率降低,实际开发过程中要尽量遵循以下两个原则:
所有不可变类都是线程安全的,例如String、LocalDate等,对于这些类不需要考虑线程安全问题。
当线程执行同步代码块时会锁定对象,通常情况下,对象会随着线程执行完毕后自动释放。如果线程在执行过程中如果出现了未处理的异常导致线程非正常结束运行,这种情况下也会释放被锁定对象。
同步方法在实现线程同步时都是“独占方法的整全部代码”,也就是说,一个线程在调用对象的a()方法时,另一个线程不能同时调用这个对象的a()方法。而同步代码块则在某线程操作一个对象时对整个对象施行了“完全垄断”。这两种同步的方式都不够精细,从而导致多线程程序运行效率不高。从JDK1.5开始,Java语言又引入了一种新的同步机制,这种同步机制能够实现更精细同步,它能够让同步的级别达到语句级,也就是说,仅在一个线程执行某几条语句时,其他线程不能同时相同对象的这几条语句,一旦这个线程执行完这几行代码后,其他线程又被允许执行这几条语句。甚至在一个线程执行被锁定的那几条语句时,其他线程可以执行相同方法当中的其他语句。不难发现:这种同步方式更加精细,程序员可以选择在任何一段代码上加锁,而为代码所加的锁称为“同步锁”。
同步锁的操作方法由Lock接口所定义,Lock接口有很多实现类,其中使用最多的实现类是ReentrantLock,ReentrantLock实现了Lock接口的加锁方法lock()和解锁方法unlock()。程序员只需要在需要被锁定的代码前后分别调用lock()和unlock()方法就能锁定这段代码。一个ReentrantLock对象可以重复利用,也就是说使用同一个对象可以对不同的代码段加锁,但加锁之后一定要解锁,否则其他线程将用于不能执行被加锁的代码。下面的【例14_11】展示了使用同步锁的作用和使用效果。
【例14_11同步锁】
Exam14_11.java
- import java.util.concurrent.locks.ReentrantLock;
- class Printer{
- private final ReentrantLock lock= new ReentrantLock();
- void print(String msg){
- try {
- lock.lock();//开始锁定
- System.out.println(msg);//第一次打印信息
- Thread.sleep(1000);
- System.out.println(msg);//第二次打印信息
- lock.unlock();//解除锁定
- for(int i = 1;i<=2;i++){
- System.out.println(Thread.currentThread().getName()+":"+i);
- Thread.sleep(1000);
- }
-
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
-
- class LockThread extends Thread{
- String msg;
- Printer printer;
- public LockThread(String name,Printer printer,String msg){
- super(name);//为线程命名
- this.printer = printer;
- this.msg = msg;
- }
-
- public void run() {
- printer.print(msg);
- }
- }
-
- public class Exam14_11 {
- public static void main(String[] args) {
- Printer printer = new Printer();
- //命名线程为A并指定其打印apple
- LockThread lt1 = new LockThread("A",printer,"apple");
- //命名线程为B并指定其打印banana
- LockThread lt2 = new LockThread("B",printer,"banana");
- lt1.start();
- lt2.start();
- }
- }
【例14_11】的Exam14_11.java文件中有三个类,其中Printer表示打印机,这个打印机的print()方法在执行打印任务时的流程是:打印两次参数字符串以及打印两个整数,并且每打印两次参数字符串和数字时中间都会睡眠1000毫秒。由于对打印参数字符串的代码加了同步锁,因此一个线程在打印参数字符串时另一个线程不能同时打印参数字符串,但可以打印数字。【例14_11】的运行结果如图14-12所示。
图14-12【例14_11】运行结果
从图14-12可以看出:A、B两个线程最先要完成的任务都是打印参数字符串,而由于打印字符串的代码被加了同步锁,因此当A线程打印参数字符串时,即使中间有一段睡眠的过程,B线程也只能等待,而当A打印完参数字符串后会解锁,此时B线程就能够开始自己打印参数字符串的操作。此外,从图中的方框内可以看到:B打印两次参数字符串的中间A打印了数字1,这证明同步锁仅锁定打印参数字符串的代码,A线程可以在B线程打印参数字符串时执行相同方法中的其他代码。
无论使用哪一种同步方式,一旦出现了两个线程相互等待对方退出同步代码的状况就是死锁状态。Java虚拟机没有对死锁的监视和处理机制,因此一旦发生死锁既不会出现任何异常,也不会给出任何提示,所有线程都处于阻塞状态,整个程序无法继续运行。由于不能寄希望于虚拟机对程序的死锁状态进行监视,所以只能由程序员自己避免死锁状况的发生。下面的【例14_12】展示了一个死锁的案例。
【例14_12死锁】
Exam14_12.java
- class A{
- public synchronized void first(B b){
- System.out.println(Thread.currentThread().getName()+"进入了A的first()方法");
- try {
- Thread.sleep(200);
- }catch (InterruptedException e){
- e.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 first(A a){
- System.out.println(Thread.currentThread().getName()+"进入了B的first()方法");
- try {
- Thread.sleep(200);
- }catch (InterruptedException e){
- e.printStackTrace();
- }
- System.out.println(Thread.currentThread().getName()+"即将调用A的last()方法");
- a.last();
- }
- public synchronized void last(){
- System.out.println("进入B的last()方法");
- }
- }
- class DeadLockThread1 extends Thread{
- A a ;
- B b;
- DeadLockThread1(String name,A a,B b){
- super(name);
- this.a = a;
- this.b = b;
- }
- @Override
- public void run() {
- a.first(b);
- }
- }
-
- class DeadLockThread2 extends Thread{
- A a ;
- B b;
- DeadLockThread2(String name,A a,B b){
- super(name);
- this.a = a;
- this.b = b;
- }
- @Override
- public void run() {
- b.first(a);
- }
- }
- public class Exam14_12
- {
- public static void main(String args[]) {
- A a = new A();
- B b = new B();
- DeadLockThread1 d1 = new DeadLockThread1("1号线程",a,b);
- DeadLockThread2 d2 = new DeadLockThread2("2号线程",a,b);
- d1.start();
- d2.start();
- }
- }
【例14_12】中,A和B两个类都定义了first()和last()两个方法,它们全部都是同步方法。A类的first()方法以B类对象为参数,并且在执行方法时要调用B类对象的last()方法。而B类的first()方法以A类对象为参数,并且在执行方法时要调用A类对象的last()方法。由此可以看出:这两个类的对象有相互调用的关系。DeadLockThread1和DeadLockThread2是两个线程类,它们在执行任务时分别会调用A和B的first()方法,并且在调用这个方法时会睡眠200毫秒。【例14_12】的运行结果如图14-13所示。
图14-13【例14_12】运行结果
从图14-13可以看出:a、b两个对象的last()方法实际上都没有被执行。这是因为t1线程在执行a对象的first()方法时,锁定了a对象中所有的同步方法,此时其他任何线程都不能a对象的同步方法,但执行first()方法时t1线程会进入睡眠状态,这样t2线程就会执行b对象的first()方法,这样b对象的所有同步方法也都被t2锁定。当t1线程醒来时,想调用b对象的last()方法,但b对象的last()方法已经被t2锁定,因此t1只能等待。而t2醒来时想调用a对象的last()方法,但a对象的last()方法已经被t1锁定,所以也只能等待。这样,t1和t2两个线程都陷入了等待对方释放对象的僵持状态。从这个例子可以很明显的看出:线程在进入死锁状态后程序只是停滞无法运行,但不会抛出异常,也没有任何提示信息,因此程序只能通过仔细检查代码来避免或破除死锁。