JAVA多线程详解(超详细)

这篇博客的整理与总结花了很长时间,内容很多。如果您发现了不对的地方欢迎指出。

1. 线程概述

关于进程和线程的知识都是操作系统里学过的内容,此处作为JAVA多线程的前置知识进行复习回顾,补充的内容可看可不看。

1.1 什么是进程

进程是系统进行资源分配的基本单位也是独立运行的基本单位。多个进程可以同时存在于内存中,能在一段时间内同时运行,在windows操作中,可以打开任务管理器看到各种各样的进程和对应的PID,并且都占用了一定的系统资源。单核CPU在同一个时刻,只能运行一个进程。所谓同时运行是宏观上的概念,微观上进程之间是在不停地快速切换。

补充:

进程具有几个基本特性:

  • 动态性。进程是程序在处理器上的一次执行过程,它因创建而产生,由调度而执行,因得不到资源而暂停,最后因撤销而消亡。
  • 并发性:如概述里所说,多个进程可以同时存在于内存中,能在一段时间内同时运行。进程的目的是使程序能与其他程序并行,以提高资源利用率。
  • 独立性:进程是一个能独立运行的基本单位,也是系统进行资源分配和调度的独立单位。
  • 异步性:进程以各自独立的、不可预知的速度向前推进。
  • 结构特征:为了描述和记录进程的运动变化过程,并使之能正确运行,每个进程都由程序段、数据段和一个进程控制块(Process Control Block,PCB)组成。

系统根据PCB感知进程的存在。PCB是进程存在的唯一标志。

1.2 什么是线程

线程又称轻量级进程(Light Weight Process),它是进程内一个相对独立的、可调度的执行单元,也是CPU的基本调度单位。一个进程由一个或多个线程组成,彼此间完成不同的工作,同时执行,称为多线程,此处的同时执行也是宏观上的。在windows操作系统中,可以打开任务管理器,找到性能分页下的资源管理器,可以查看每个进程所拥有的线程数。

JAVA虚拟机是一个进程,当中默认包含主线程(main),可通过代码创建多个独立线程,与main并发执行。

补充:

  • 线程的引入

    在操作系统中引入线程,是为了减少程序并发执行时所付出的时空开销,使操作系统具有更好的并发性,为了说明这一点,先来回顾一下进程的两个基本属性:

    1. 进程是一个拥有资源的独立单位。
    2. 进程同时又是一个可以被处理器独立调度和分配的单元。

    上述两个属性构成了程序并发执行的基础。然而,为了使进程能并发执行,操作系统还必须进行一系列的操作,如进程的创建、撤销进程和进程切换。在进行这些操作时,操作系统要为进程分配资源及回收资源,为运行进程保存现场信息,这些工作都需要付出较多的时空开销。为了使多个程序更好地并发执行,并尽量减少操作系统的开销,操作系统设计者考虑将进程的两个属性分离开来,让线程去完成第二个基本属性的任务,而进程只完成第一个基本属性的任务。

  • 线程的定义

    线程的定义存在多种不同的提法,前文概述中已阐述一二,此处进行补充说明。线程本身不能单独运行,只能包含在进程中,只能在进程中执行。线程自己基本上不拥有资源,只拥有一点在运行时必不可少的资源,但它可以与同属一个进程的其他线程共享该进程资源。多线程是指一个进程中有多个线程,这些线程共享该进程资源。如果一个线程修改了一个数据项,其他线程可以了解和使用此结果数据。一个线程打开并读一个文件时,同一进程中的其他线程也可以同时读此文件。

1.3 进程和线程的区别

  1. 进程是操作系统资源分配的基本单位,而线程是CPU的基本调度单位。
  2. 一个程序运行后之后有一个进程。
  3. 一个进程可以包含多个线程,但是至少需要有一个线程,否则这个线程是没有意义的。
  4. 进程间不能共享数据段地址,但同进程的线程之间可以。

1.4 线程的组成

  • 任何一个线程都具有基本的组成部分:
    • CPU时间片:操作系统会为每个线程分配执行时间。
    • 运行数据:
      • 堆空间:存储线程需要使用的对象,多个线程可以共享堆中的对象。
      • 栈空间:存储线程需要使用的局部变量,每个线程都拥有独立的栈。
    • 线程的逻辑代码

1.5 线程的特点

  • 线程抢占式执行。
    • 效率高。
    • 可防止单一线程长时间独占CPU。
  • 在单核CPU中,宏观上同时执行,微观上顺序执行。

2. 线程的创建

  • 创建线程的三种方式:
    1. 继承Thread类,重写run方法。
    2. 实现Runnable接口。
    3. 实现Callable接口。(6.4节演示)

2.1 创建线程(一)

/**
 * 线程类
 */
public class MyThread extends Thread{
	@Override
	public void run() {
		for(int i=0;i<10;i++) {
			System.out.println(i);
		}
	}
}
public class testMyThread {
	public static void main(String[] args) {
		//创建线程对象
		MyThread myThread=new MyThread();
		//启动子线程
		myThread.start();
		for(int i=0;i<10;i++) {
			System.out.println("----"+i);
		}
	}
}

运行结果如下:

----0
0
1
2
3
4
5
6
----1
7
8
----2
----3
----4
----5
----6
----7
----8
9
----9

每次运行后得到的结果都不一样,而且主线程和子线程都是交替执行的,并且是抢占式执行。

需要注意的是,在main方法中需要调用线程类的start方法来启动线程,如果调用run方法就相当于调用了一个普通类中的方法,那么还是由主线程执行。

2.2 获取和修改线程名称

  • 获取线程ID和线程名称

    1. 在Thread的子类中调用this.getId()this.getName()
    2. 使用Thread.currentThread().getId()Thread.currentTread().getName()
    /**
     * 线程类
     * 获取线程名方法演示
     */
    public class MyThread extends Thread{
    	@Override
    	public void run() {
    		for(int i=0;i<10;i++) {
    			//第一种方法
    			System.out.println("线程ID:"+this.getId()+" "+"线程名:"+this.getName()+" "+i);
    			//第二种方法
    			//System.out.println("线程ID:"+Thread.currentThread().getId()+" "+"线程名:"+Thread.currentThread().getName());
    		}
    	}
    }
    

    使用第一种方法的线程类必须继承Thread父类,否则不能使用这两个方法。

    第二种方法调用的静态方法currentThread表示获取当前线程,哪个线程执行的当前代码就获取谁。

    testMyThread类中再新创建一个线程类对象并启动,可以看到如下结果:

    主线程:0
    线程ID:11 线程名:Thread-1 0
    线程ID:10 线程名:Thread-0 0
    线程ID:11 线程名:Thread-1 1
    主线程:1
    线程ID:11 线程名:Thread-1 2
    线程ID:10 线程名:Thread-0 1
    线程ID:10 线程名:Thread-0 2
    线程ID:10 线程名:Thread-0 3
    线程ID:10 线程名:Thread-0 4
    线程ID:10 线程名:Thread-0 5
    线程ID:10 线程名:Thread-0 6
    线程ID:10 线程名:Thread-0 7
    线程ID:10 线程名:Thread-0 8
    线程ID:10 线程名:Thread-0 9
    线程ID:11 线程名:Thread-1 3
    线程ID:11 线程名:Thread-1 4
    线程ID:11 线程名:Thread-1 5
    线程ID:11 线程名:Thread-1 6
    线程ID:11 线程名:Thread-1 7
    线程ID:11 线程名:Thread-1 8
    线程ID:11 线程名:Thread-1 9
    主线程:2
    主线程:3
    主线程:4
    主线程:5
    主线程:6
    主线程:7
    主线程:8
    主线程:9
    
  • 修改线程名称

    1. 调用线程对象的setName()方法。
    2. 使用线程子类的构造方法赋值。
    //使用setName方法
    myThread.setName("子线程1");
    myThread.start();
    myThread2.setName("子线程2");
    myThread2.start();
    

    使用该方法需要注意必须在线程启动之前修改线程名,否则就没有意义了。

    //使用构造方法
    public class MyThread extends Thread{
    	public MyThread() {		
    	}
    	public MyThread(String name) {
    		super(name);
    	}
    	@Override
    	public void run() {
    		//略
    	}
    }
    

    线程类的带参构造方法调用了父类的构造方法,也可以把name赋给线程名。

    //创建线程对象
    MyThread myThread=new MyThread("子线程1");
    MyThread myThread2=new MyThread("子线程2");
    

    在创建线程对象时可以直接通过构造方法为线程修改名字,运行后结果如下:

    主线程:0
    线程ID:11 线程名:子线程2 0
    线程ID:11 线程名:子线程2 1
    线程ID:11 线程名:子线程2 2
    线程ID:11 线程名:子线程2 3
    线程ID:11 线程名:子线程2 4
    线程ID:11 线程名:子线程2 5
    线程ID:10 线程名:子线程1 0
    线程ID:11 线程名:子线程2 6
    主线程:1
    线程ID:11 线程名:子线程2 7
    线程ID:10 线程名:子线程1 1
    线程ID:11 线程名:子线程2 8
    主线程:2
    线程ID:11 线程名:子线程2 9
    线程ID:10 线程名:子线程1 2
    线程ID:10 线程名:子线程1 3
    线程ID:10 线程名:子线程1 4
    线程ID:10 线程名:子线程1 5
    主线程:3
    主线程:4
    主线程:5
    线程ID:10 线程名:子线程1 6
    主线程:6
    主线程:7
    主线程:8
    线程ID:10 线程名:子线程1 7
    主线程:9
    线程ID:10 线程名:子线程1 8
    线程ID:10 线程名:子线程1 9
    

2.3 一个线程小案例

/**
 * 使用继承Thread类实现4个窗口各卖100张票
 */
public class TicketWin extends Thread{
	private int ticket=100;
	public TicketWin() {
	}
	public TicketWin(String name) {
		super(name);
	}
	@Override
		public void run() {
			while(true) {
				if(ticket>0) {
					ticket--;
					System.out.println(Thread.currentThread().getName()+"卖出了一张票,还剩"+ticket+"张。");
				}else {
					break;
				}
			}
		}
}
public class testTickets {
	public static void main(String[] args) {
		//四个窗口
		TicketWin ticketWin1=new TicketWin("窗口1");
		TicketWin ticketWin2=new TicketWin("窗口2");
		TicketWin ticketWin3=new TicketWin("窗口3");
		TicketWin ticketWin4=new TicketWin("窗口4");
		ticketWin1.start();
		ticketWin2.start();
		ticketWin3.start();
		ticketWin4.start();
	}
}

运行代码后结果如下:

窗口1卖出了一张票,还剩99张。
窗口3卖出了一张票,还剩99张。
窗口1卖出了一张票,还剩98张。
窗口2卖出了一张票,还剩99张。
窗口1卖出了一张票,还剩97张。
窗口1卖出了一张票,还剩96张。
窗口1卖出了一张票,还剩95张。
窗口3卖出了一张票,还剩98张。
窗口1卖出了一张票,还剩94张。
窗口4卖出了一张票,还剩99张。
窗口2卖出了一张票,还剩98张。
窗口4卖出了一张票,还剩98张。
窗口1卖出了一张票,还剩93张。
窗口3卖出了一张票,还剩97张。
窗口1卖出了一张票,还剩92张。
窗口4卖出了一张票,还剩97张。
窗口2卖出了一张票,还剩97张。
窗口4卖出了一张票,还剩96张。
窗口1卖出了一张票,还剩91张。
窗口3卖出了一张票,还剩96张。
//略

2.4 创建线程(二)

/**
 * 实现Runnable接口
 */
public class MyRunnable implements Runnable{
	@Override
	public void run() {
		for(int i=0;i<10;i++) {
			System.out.println(Thread.currentThread().getName()+":"+i);
		}
	}
}
public class testRunnable {
	public static void main(String[] args) {
        //创建MyThread对象,实现run功能
        MyRunnable myRunnable=new MyRunnable();
        //创建线程类
        Thread thread=new Thread(myRunnable, "子线程");
        //启动线程
        thread.start();
        for(int i=0;i<10;i++) {
            System.out.println("主线程:"+i);
        }
    }
}

结果如下:

主线程:0
子线程:0
主线程:1
子线程:1
主线程:2
子线程:2
主线程:3
子线程:3
主线程:4
子线程:4
子线程:5
子线程:6
子线程:7
子线程:8
子线程:9
主线程:5
主线程:6
主线程:7
主线程:8
主线程:9

上述代码段中使用的构造方法是Thread((Runnable target, String name),Runnable是一个接口类,方法体只包含一个抽象方法run。既然参数传进来的是接口类,那么也可以使用匿名内部类(如果线程只使用一次):

public class testRunnable {
    public static void main(String[] args) {
        //创建可运行对象
        Runnable runnable=new Runnable() {	
            @Override
            public void run() {
                for(int i=0;i<10;i++) {
                    System.out.println(Thread.currentThread().getName()+":"+i);
                }
            }
        };
        //创建线程对象
        Thread thread=new Thread(runnable, "子线程");
        thread.start();
        for(int i=0;i<10;i++) {
            System.out.println("主线程:"+i);
        }
    }
}

2.5 Runnable小案例

  1. 实现四个窗口共卖100张票

    /**
     * 票类,实现买票功能
     */
    public class Ticket implements Runnable{
    	int ticket=100;
    	@Override
    	public void run() {
    		while(ticket>0) {
    			System.out.println(Thread.currentThread().getName()+"卖出了一张票,还剩"+(--ticket)+"张。");
    		}
    	}
    }
    
    public class testTicket {
    	public static void main(String[] args) {
    		//创建票对象
    		Ticket ticket=new Ticket();
    		//创建线程对象
    		Thread w1=new Thread(ticket,"窗口1");
    		Thread w2=new Thread(ticket,"窗口2");
    		Thread w3=new Thread(ticket,"窗口3");
    		Thread w4=new Thread(ticket,"窗口4");
    		w1.start();
    		w2.start();
    		w3.start();
    		w4.start();
    	}
    }
    

    以这样的逻辑写代码是没有错的,但是在运行的时候大家会发现控制台打印的似乎并没有实现“共享”,但最终都会有某一个窗口卖完票:

    窗口1卖出了一张票,还剩99张。
    窗口4卖出了一张票,还剩96张。
    窗口3卖出了一张票,还剩97张。
    窗口2卖出了一张票,还剩98张。
    窗口3卖出了一张票,还剩93张。
    窗口4卖出了一张票,还剩94张。
    窗口1卖出了一张票,还剩95张。
    窗口1卖出了一张票,还剩89张。
        ......
    窗口4卖出了一张票,还剩2张。
    窗口4卖出了一张票,还剩1张。
    窗口4卖出了一张票,还剩0张。
    窗口3卖出了一张票,还剩16张。
    窗口2卖出了一张票,还剩17张。
    

    这是因为线程是抢夺式占用CPU,每个线程都以各自的不可预知的进度执行。等后面讲完线程的同步之后你可以再来理解这个案例。

  2. 今天是月初你爸往你银行卡存钱同时你从卡里取钱使用程序模拟这个过程

    /**
     * 银行卡(普通类)
     */
    public class BandCard {
    	private int Money;
    	public int getMoney() {
    		return Money;
    	}
    	public void setMoney(int money) {
    		Money = money;
    	}	
    }
    
    /**
     * 存钱功能(功能类)
     */
    public class AddMoney implements Runnable{
    	BandCard card;
    	public AddMoney(BandCard bandCard) {
    		card=bandCard;
    	}
    	@Override
    	public void run() {
    		//存10次
    		for(int i=0;i<10;i++) {
    			//往卡里存200
    			card.setMoney(card.getMoney()+200);
    			System.out.println(Thread.currentThread().getName()+"存了200元,卡里余额为:"+(card.getMoney()));
    		}
    	}
    }
    
    /**
     * 取钱功能
     */
    public class SubMoney implements Runnable{
    	BandCard card;
    	public SubMoney(BandCard bandCard) {
    		card=bandCard;
    	}
    	@Override
    	public void run() {
    		for(int i=0;i<10;i++) {
    			if(card.getMoney()>=200) {
    				//往卡里取200
    				card.setMoney(card.getMoney()-200);
    				System.out.println(Thread.currentThread().getName()+"取了200元,卡里还剩"+card.getMoney());
    			}else {
    				//余额不足,回退这次取钱过程,否则有效的取钱次数可能不到10次
    				i--;
    				System.out.println("余额不足");
    			}
    		}
    	}
    }
    
    public class testBankCard {
    	public static void main(String[] args) {
    		//创建银行卡对象
    		BandCard bandCard=new BandCard();
    		//创建功能对象
    		AddMoney addMoney=new AddMoney(bandCard);
    		SubMoney subMoney=new SubMoney(bandCard);
    		//创建线程对象并启动
    		new Thread(addMoney,"爸爸").start();
    		new Thread(subMoney,"我").start();
    	}
    }
    

    运行代码结果如下:

    爸爸存了200元,卡里余额为:200
    我取了200元,卡里还剩0
    余额不足
    余额不足
    余额不足
    余额不足
    余额不足
    爸爸存了200元,卡里余额为:200
    爸爸存了200元,卡里余额为:400
    爸爸存了200元,卡里余额为:600
    爸爸存了200元,卡里余额为:800
    爸爸存了200元,卡里余额为:1000
    爸爸存了200元,卡里余额为:1000
    爸爸存了200元,卡里余额为:1200
    爸爸存了200元,卡里余额为:1400
    爸爸存了200元,卡里余额为:1600
    我取了200元,卡里还剩800
    我取了200元,卡里还剩1400
    我取了200元,卡里还剩1200
    我取了200元,卡里还剩1000
    我取了200元,卡里还剩800
    我取了200元,卡里还剩600
    我取了200元,卡里还剩400
    我取了200元,卡里还剩200
    我取了200元,卡里还剩0
    

    当然每次运行结果是不一样的,而且可以注意到控制台打印的数据似乎并不“正确”,原因同上一个案例,不过最终的结果卡里还是0元。这个案例也可以写成匿名内部类以减少代码量,缺点是可读性差,这里不再演示。


3. 线程的基本状态

线程的基本状态可以分为:

  1. 初始状态

    当线程对象被创建(new)之后即为初始状态。

  2. 就绪状态

    线程对象调用start方法之后进入就绪状态,此时只要获得了处理器便可以立即执行。

  3. 运行状态

    获得处理器之后,则进入运行状态,直到所分配的时间片结束,然后继续进入就绪状态。

  4. 等待状态

    因为发生某种事情而无法继续执行下去,例如调用sleep方法时线程进入限期等待,因某线程调用join使当前线程进入无限期等待。下一节会提到这两个方法。

  5. 终止状态

    主线程(main)结束或者该线程的run方法结束则进入终止状态,并释放CPU。


4. 线程常用方法

  • public static void sleep(long millis)

    当前线程主动休眠millis毫秒。

    /**
     * 演示sleep的使用
     */
    public class test {
    	public static void main(String[] args) throws InterruptedException {
    		for(int i=0;i<10;i++) {
    			System.out.println(i);
    			//(主线程)每隔一秒打印一次
    			Thread.sleep(1000);
    		}
    	}
    }
    
  • public static void yield()

    当前线程主动放弃时间片,回到就绪状态,竞争下一次时间片。

    /**
     * 演示yield的使用
     */
    public class MyRunnable implements Runnable{
    	@Override
    	public void run() {
    		for(int i=0;i<10;i++) {
    			System.out.println(Thread.currentThread().getName()+":"+i);
    			//打印一次后主动放弃时间片,释放CPU
    			Thread.yield();
    		}
    	}
    }
    

    在测试类里创建两个线程对象执行上述代码,所得到的打印结果会更接近于交替打印。

  • public final void join()

    允许其他线程加入到当前线程中。当某线程调用该方法时,加入并阻塞当前线程,直到加入的线程执行完毕,当前线程才继续执行。

    /**
     * 演示join的使用
     */
    public class MyRunnable implements Runnable{
    	@Override
    	public void run() {
    		for(int i=0;i<10;i++) {
    			System.out.println(Thread.currentThread().getName()+":"+i);			
    		}
    	}
    }
    
    public class test {
    	public static void main(String[] args) throws InterruptedException{
    		MyRunnable myRunnable=new MyRunnable();
    		Thread thread=new Thread(myRunnable,"子线程");
    		thread.start();
    		//加入到当前线程(主线程main),并阻塞当前线程
    		//必须要在线程启动之后调用
    		thread.join();
    		for(int i=0;i<10;i++) {
    			System.out.println(i);
    		}
    	}
    }
    

    注释掉join这行代码,就和之前运行的结果一样,两个线程抢占执行;调用join之后结果如下:

    子线程:0
    子线程:1
    子线程:2
    子线程:3
    子线程:4
    子线程:5
    子线程:6
    子线程:7
    子线程:8
    子线程:9
    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    

    子线程加入到主线程并阻塞了主线程,子线程执行完毕后才恢复主线程的运行。

  • pubic final void setPriority(int newPriority)

    改变该线程的优先级,线程优先级为1-10,默认为5,优先级越高,表示获取CPU机会越多。

    把前面MyRunnable类中的循环次数写多一点,比如改成50次,然后在测试类里设置优先级:

    public class testPriority {
    	public static void main(String[] args) {
    		MyRunnable myRunnable=new MyRunnable();
    		Thread t1=new Thread(myRunnable,"子线程1");
    		Thread t2=new Thread(myRunnable,"子线程2");
    		Thread t3=new Thread(myRunnable,"子线程3");
    		t1.setPriority(1);
    		t3.setPriority(10);
    		t1.start();
    		t2.start();
    		t3.start();
    	}
    }
    

    运行之后会发现每次运行的结果都是子线程1最后执行完,说明优先级设置成功了。

  • public final void setDaemon(boolean on)

    如果参数为true,则标记该线程为守护线程。

    在JAVA中线程有两类:用户线程(前台线程)、守护线程(后台线程)。守护可以理解为守护用户线程。如果程序中所有用户线程都执行完毕了,守护线程会自动结束。垃圾回收线程属于守护线程

    /**
     * 演示守护线程
     */
    public class MyThread extends Thread{
    	public MyThread() {		
    	}
    	public MyThread(String name) {
    		super(name);
    	}
    	@Override
    	public void run() {
    		for(int i=0;i<50;i++) {
    			System.out.println(Thread.currentThread().getName()+":"+i);
    			try {
    				Thread.sleep(500);
    			} catch (InterruptedException e) {
    				e.printStackTrace();
    			}
    		}
    	}
    }
    
    public class test {
    	public static void main(String[] args) throws InterruptedException{
    		MyThread thread=new MyThread();
            //必须在start之前设置
    		thread.setDaemon(true);
    		thread.start();
    		for(int i=0;i<10;i++) {
    			System.out.println(i);
    			Thread.sleep(200);
    		}
    	}
    }
    

    我们知道线程争夺的情况,但当某个线程被设置成守护线程时,结果如下:

    0
    Thread-00
    1
    2
    Thread-01
    3
    4
    Thread-02
    5
    6
    7
    Thread-03
    8
    9
    Thread-04
    

    当主线程执行完毕后,子线程只打印了4次,但因为前者的结束而结束。


5. 线程安全

这里有一个线程安全问题,假设有A、B两个线程,他们都往一个数组中的index位置存入一个数据并且执行index+1。

A线程存入"hello",然后使index++;B线程存入"world",然后使index++。当这两个线程同时执行时,数组中存入的结果会是什么?

public class ThreadSafe {
	private static int index=0;
	public static void main(String[] args) throws InterruptedException {
		String[] strings=new String[5];
		//存入hello
		Runnable runnableA=new Runnable() {		
			@Override
			public void run() {
				strings[index]="hello";
				index++;
			}
		};
		//存入world
		Runnable runnableB=new Runnable() {		
			@Override
			public void run() {
				strings[index]="world";
				index++;
			}
		};
		Thread A=new Thread(runnableA);
		Thread B=new Thread(runnableB);
		A.start();
		B.start();
		//加入主线程,用来阻塞主线程使最后的输出语句最后执行
		A.join();
		B.join();		
		System.out.println(Arrays.toString(strings));
	}	
}

多次执行代码,发现得到的结果并不一致,有可能出现[hello, world, null, null, null],也有可能出现[world, null, null, null, null];以第二个结果为例,当线程A存入hello之后,CPU马上就被线程B所抢夺,B存入了world覆盖了A存入的hello,这之后才执行了各自的index++

多线程安全问题

  • 当多线程并发访问临界资源时,如果破坏了原子操作,可能会造成数据不一致。
    • 临界资源:共享资源(对于同一个对象),一次仅允许一个线程使用,才可以保证其正确性。
    • 原子操作:不可分割的多步操作,被视为一个整体,其顺序和步骤不可打乱或缺省,比如上一段代码的存hello和存world应当被看成两个原子操作。

补充:

临界资源和临界区(针对线程而言):

线程在运行过程中,会与同一进程内的其他线程共享资源,把同时只允许一个线程使用的资源称为临界资源。为了保证临界资源的正确使用,可以把临界资源的访问分成以下四个部分:

  1. 进入区。为了进入临界区使用临界资源,在进入区要检查是否可以进入临界区;如果可以进入临界区,通常设置相应的“正在访问临界区”标志,以阻止其他线程同时进入临界区。
  2. 临界区线程用于访问临界资源的代码又称临界段
  3. 退出区。临界区后用于将“正在访问临界区”标志清除部分。
  4. 剩余区。线程中除上述3部分以外的其他部分。

简单来说,临界资源是一种系统资源,需要不同的线程互斥访问,例如前文代码中的数组;而临界区则是每个线程中访问临界资源的一段代码,是属于对应线程的,前文代码中的存hello和存world就可以看成是两个临界区,临界区前后需要设置进入区和退出区以进行检查和恢复。

JAVA中,在程序应用里要保证线程的安全性就需要用到同步代码块

5.1 同步方式(1)

  • 同步代码块:

    //对临界资源对象加锁
    synchronized(临界资源对象){
        //代码(原子操作)
    }
    

    把上文存hello和存world两个临界区放进同步代码块中就可以保证输出结果不会出现覆盖的情况:

    synchronized (strings) {
        strings[index]="hello";
        index++;
    }			
    
    synchronized (strings) {
        strings[index]="world";
        index++;
    }			
    

    现在就能解决之前的卖票小案例,你可以从目录跳转到Runnable小案例,并使用同步代码块来实现互斥访问票这个临界资源:

    public void run() {
        //注:这个锁可以是任意一种对象,但不能直接在里面new
        synchronized (this) {
            while(ticket>0) {			
                System.out.println(Thread.currentThread().getName()+"卖出了一张票,还剩"+(--ticket)+"张。");
            }
        }
    }
    

    再次运行你就能看到期望的结果,这里不再演示。

    注:

    每个对象都有一个互斥锁标记用来分配给线程的

    只有拥有对象互斥锁标记的线程,才能进入对该对象加锁的同步代码块。

    线程退出同步代码块时,会释放相应的互斥锁标记。

    前文的银行卡存取案例同样也能解决了:

    //取钱
    synchronized (card) {
        if(card.getMoney()>=200) {
            card.setMoney(card.getMoney()-200);
            System.out.println(Thread.currentThread().getName()+"取了200元,卡里还剩"+card.getMoney());
        }else {
            i--;
            System.out.println("余额不足");
        }
    }			
    
    //存钱
    synchronized (card) {
        card.setMoney(card.getMoney()+200);
        System.out.println(Thread.currentThread().getName()+"存了200元,卡里余额为:"+(card.getMoney()));
    }
    

    运行一下,没有问题。

  • 线程的状态阻塞

    当线程访问临界区(同步块代码)时,如果没有拿到访问锁,便进入阻塞状态。

5.2 线程基本状态(源码补充)

在JDK1.5之后,把就绪状态和运行状态合成了一个Runnable状态,可以通过public Thread.State getState()方法获取当前线程的状态。

我们可以通过源码来查看一下这几个状态:

public enum State {
    NEW,
    RUNNABLE,
    BLOCKED,
    WAITING,
    TIMED_WAITING,
    TERMINATED;
}

这个State返回类型实际上一个枚举类。

  • NEW 初始状态

    线程还没有启动时的状态。

  • RUNNABLE 就绪状态和执行状态

    线程启动时的状态。线程被JVM所执行但它还可能需要一些来自操作系统的其他资源才能执行。

  • BLOCKED 阻塞状态

    线程被一个监听锁所阻塞时的状态。

  • WAITING (无期限)等待状态

    线程正在等待时的状态。线程被以下方法所调用就会进入等待状态:

    • Object.wait无参方法
    • Thread.join无参方法
    • LockSupport.park

    wait方法可以让当前线程进入等待状态,需要其他线程调用此线程对象的notify方法或者notifyAll方法来唤醒此线程;调用join方法的线程需要等到被调用线程终止才能结束等待状态。

  • TIMED_WAITING 有限等待状态

    线程在指定时间后才能结束等待的一种等待状态。是由于调用了以下方法所引起的一种状态:

    • Thread.sleep
    • Object.wait带参方法
    • Thread.join带参方法
    • LockSupport.parkNanos
    • LockSupport.parkUntil
  • TERMINATED 终止状态

    线程终止时的状态。该线程已经执行完毕。

5.3 同步方式(2)

  • 同步方法

    //对当前对象(this)加锁
    synchronized 返回值类型 方法名称(形参列表){
        //代码(原子操作)
    }
    

    用这种同步方式改写2.5节中的第一个案例:

    public synchronized void run() {
        while(ticket>0) {			
            System.out.println(Thread.currentThread().getName()+"卖出了一张票,还剩"+(--ticket)+"张。");
        }
    }
    

    得到的也是一样的正确结果。在这个同步方法中,锁就是this当前对象;如果是静态类,那么锁就是类对象,就相当于在同步代码块的括号里写XXX.class,XXX代表当前的类。

    注:

    只有拥有对象互斥锁标记的线程,才能进入该对象加锁的同步方法中。线程退出同步方法时,会释放相应的互斥锁标记。

  • 同步规则

    • 只有在调用包含同步代码块的方法,或者同步方法时,才需要对象的锁标记。

      临界区(互斥执行)才需要加锁。

    • 如调用不包含同步代码块的方法,或普通方法时,则不需要锁标记,可直接调用。

    • 已知JDK中线程安全的类:

      • StringBuffer
      • Vector
      • Hashtable
      • 以上类中的公开方法,均为synchronized修饰的同步方法。

5.4 经典问题(死锁)

死锁

  • 当第一个线程拥有A对象锁标记,并等待B对象锁标记,同时第二个线程拥有B对象锁标记,并等待A对象锁标记时,产生死锁。
  • 一个线程可以同时拥有多个对象的锁标记,当线程阻塞时,不会释放已经拥有的锁标记,由此可能造成死锁。

补充:

死锁的概念(对于线程):

当多个线程因竞争系统资源或互相通信而处于半永久阻塞状态时,若无外力作用,这些线程都将无法向前推进。这些线程中的每一个线程,均无限期地等待此组线程中某个其他线程占用的、自己永远无法得到的资源,这种现象称为死锁。

资源分类

现代操作系统所管理的资源类型十分丰富,并且可以从不同角度出发对其进行分类,例如,可以把资源分为可剥夺资源和不可剥夺资源。

  • 可剥夺资源是指虽然资源占有者线程需要使用该资源,但另一个线程可以强行把该资源从占有者线程处剥夺过来自己使用。
  • 不可剥夺资源是指除非占有者线程不再需要使用该资源而主动释放资源,否则其他线程不得在占有者线程使用资源过程中强行剥夺。

死锁产生的原因是竞争资源。可剥夺资源的竞争不会引起死锁。更进一步看,死锁产生的原因是系统资源不足和线程推进顺序不当;后者是重要原因而前者是根本原因。

通过一个小案例来演示死锁的产生,假如两个人A和B在桌子上同时吃饭,桌上只有一双筷子,当一个人拥有两根筷子的时候才能吃:

/**
 * 锁对象(筷子)
 */
public class Chopsticks {
}
public class testChopsticks {
	public static void main(String[] args) {
		//创建两个锁对象(两根筷子)
		Chopsticks chopsticks1=new Chopsticks();
		Chopsticks chopsticks2=new Chopsticks();
		
		Runnable A=new Runnable() {		
			@Override
			public void run() {
				//持有第一根筷子
				synchronized (chopsticks1) {
					System.out.println("A拿到了一根筷子。");
					//持有第二根筷子
					synchronized (chopsticks2) {
						System.out.println("A拿到了两根筷子,开始恰饭。");
					}
				}
			}
		};
		
		Runnable B=new Runnable() {		
			@Override
			public void run() {
				//持有第一根筷子
				synchronized (chopsticks2) {
					System.out.println("B拿到了一根筷子。");
					//持有第二根筷子
					synchronized (chopsticks1) {
						System.out.println("B拿到了两根筷子,开始恰饭。");
					}
				}
			}
		};
		
		new Thread(A).start();
		new Thread(B).start();
	}
}

运行之后程序进入死锁状态,并且无限期地等待下去:

//控制台打印(程序未结束)
B拿到了一根筷子。
A拿到了一根筷子。

A和B各持有一根筷子,并且都在等待对方的一根筷子,导致两个人都吃不了饭。可以通过sleep方式使其中一个线程休眠一小会,A(B)吃完B(A)再吃;或者把A(B)同步代码块中的锁换一下位置,一开始两个人都抢同一根筷子,没抢到的就等另一个吃完饭。

5.5 线程通信

在2.5节中的第二个案例银行卡存取中,打印出了很多余额不足,取钱线程在银行卡里没钱时也在不停地取钱,为了让取钱线程只在银行卡里有钱时再取,就需要实现线程间通信。

  • 等待
    • public final void wait()
    • public final void wait(long timeout)
    • 必须在对obj加锁的同步代码块中调用。在一个线程中,调用obj.wait()时,此线程会释放其拥有的所有锁标记。同时此线程阻塞在obj的等待队列中。总而言之,就是释放锁,进入等待队列。
  • 通知
    • public final void notify()
    • public final void notifyAll()
    • 进入等待的线程需要其他线程调用该线程的通知方法来将其唤醒。

还是银行卡存取案例,此处应用线程通信再来演示:

/**
 * 银行卡(普通类)
 */
public class BandCard {
    private double Money;
    //标志,true表示卡里有钱-可取,false表示无钱-可存
    boolean flag=false;	
    public synchronized void put(double money) throws InterruptedException {
        //有钱不用存
        if (flag) {
            //进入等待队列(锁.wait),同时释放锁和CPU
            this.wait();
        }
        this.Money+=money;
        System.out.println("你爸存了"+money+"元,卡里还剩"+this.Money+"元。");
        //存完之后卡里有钱
        flag=true;
        //唤醒取钱线程
        this.notify();
    }
    public synchronized void take(double money) throws InterruptedException {	
        //没钱不能取
        if (!flag) {
            this.wait();
        }
        this.Money-=money;
        System.out.println("你取了"+money+"元,卡里还剩"+this.Money+"元。");
        flag=false;
        //唤醒存钱线程
        this.notify();
    }
}
/**
 * 存钱功能(功能类)
 */
public class AddMoney implements Runnable{
    BandCard card;
    public AddMoney(BandCard bandCard) {
        card=bandCard;
    }
    @Override
    public void run() {
        //存10次
        for(int i=0;i<10;i++) {
            try {
                card.put(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
/**
 * 取钱功能
 */
public class SubMoney implements Runnable{
    BandCard card;
    public SubMoney(BandCard bandCard) {
        card=bandCard;
    }
    @Override
    public void run() {
        //取10次
        for(int i=0;i<10;i++) {
            try {
                card.take(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
public class testBankCard {
    public static void main(String[] args) {
        //创建银行卡对象
        BandCard bandCard=new BandCard();
        //创建操作
        AddMoney addMoney=new AddMoney(bandCard);
        SubMoney subMoney=new SubMoney(bandCard);
        //创建线程对象并启动
        new Thread(addMoney).start();
        new Thread(subMoney).start();
    }
}

运行后得到如下正确交替结果:

你爸存了200.0元,卡里还剩200.0元。
你取了200.0元,卡里还剩0.0元。
你爸存了200.0元,卡里还剩200.0元。
你取了200.0元,卡里还剩0.0元。
你爸存了200.0元,卡里还剩200.0元。
你取了200.0元,卡里还剩0.0元。
你爸存了200.0元,卡里还剩200.0元。
你取了200.0元,卡里还剩0.0元。
你爸存了200.0元,卡里还剩200.0元。
你取了200.0元,卡里还剩0.0元。
你爸存了200.0元,卡里还剩200.0元。
你取了200.0元,卡里还剩0.0元。
你爸存了200.0元,卡里还剩200.0元。
你取了200.0元,卡里还剩0.0元。
你爸存了200.0元,卡里还剩200.0元。
你取了200.0元,卡里还剩0.0元。
你爸存了200.0元,卡里还剩200.0元。
你取了200.0元,卡里还剩0.0元。
你爸存了200.0元,卡里还剩200.0元。
你取了200.0元,卡里还剩0.0元。

多存多取问题:

但是如果往代码中再加入两个线程,比如你妈担心你钱不够用,也给你存钱;你妹妹来找你玩,往你卡里取钱。这时候就出现问题了:

你爸存了200.0元,卡里还剩200.0元。
你妹取了200.0元,卡里还剩0.0元。
你妈存了200.0元,卡里还剩200.0元。
你妹取了200.0元,卡里还剩0.0元。
你取了200.0元,卡里还剩-200.0元。
......
你取了200.0元,卡里还剩-2000.0元。
你妈存了200.0元,卡里还剩-1800.0元。
你爸存了200.0元,卡里还剩-1600.0元。

不仅余额负数,程序也并没有结束而进入永久等待的状态。

出现余额负数的原因是当“你”,“你妹”两个取钱线程都因为flag为false而进入等待队列时,然后“你妹”被存钱线程所唤醒,此时余额为200,被唤醒的“你妹”继续取钱,此时余额为0,然后唤醒“你”,注意,“你”被唤醒后接着从wait语句之后往下执行取钱操作,此时余额为-200。问题就出在“你”这里,“你”被唤醒后是继续往下执行的,并没有重新判断flag,解决办法很简单,将if改为while就可以了,如果flag为false被唤醒的你就会接着等待:

你爸存了200.0元,卡里还剩200.0元。
你取了200.0元,卡里还剩0.0元。
你妈存了200.0元,卡里还剩200.0元。
你取了200.0元,卡里还剩0.0元。

余额负数的问题解决了,但是程序陷入永久等待的问题还没解决,分析下原因:

  1. 你爸存钱成功,flag为true,余额200
  2. 你妈存钱失败,进入等待队列;(你妈)
  3. 你爸存钱失败,进入等待队列;(你妈,你爸)
  4. 你取钱成功,flag为false,唤醒你妈,余额为0;(你爸)
  5. 你妹取钱失败,进入等待队列;(你爸,你妹)
  6. 你取钱失败,进入等待队列;(你爸,你妹,你)
  7. 你妈存钱成功,flag为true,唤醒你,余额为200;(你爸,你妹)
  8. 你妈存钱失败,进入等待队列;(你爸,你妹,你妈)
  9. 你取钱成功,flag为false,唤醒你妹,余额为0;(你爸,你妈)
  10. 你妹取钱失败,进入等待队列;(你爸,你妈,你妹)
  11. 你取钱失败,进入等待队列;(你爸,你妈,你妹,你)

至此四个线程全部进入等待状态,在没有别的线程将其唤醒的情况下将陷入无限期等待。原因出在第9步,如果取钱线程“你”唤醒的是存钱线程,那么程序就会正常执行。修改方式也很简单,将代码中notify方法改成notifyAll就可以了,一次唤醒所有线程。结果正常运行不再演示,这里说这么多主要是体会线程同步的一个过程。

5.6 经典问题(生产者消费者)

若干个生产者在生产产品,这些产品将提供给若干个消费者去消费,为了使生产者和消费者能并发执行,在两者之间设置一个能存储多个产品的缓冲区,生产者将生产的产品放入缓冲区中,消费者从缓冲区取走产品进行消费,显然生产者和消费者之间必须保持同步,即不允许消费者到一个空的缓冲区中取产品,也不允许生产者向一个满的缓冲区中放入产品。

这个例子和上一节的案例差不多,理解了上一节的案例这个问题就能解决,以下是演示代码:

public class ProductBuf {
    //产品仓库(缓冲区)
    String[] products=new String[6];
    //下标从-1开始,往0到5位置放产品
    int index=-1;
    //生产者生产
    public synchronized void Productor() {
        while(index>=5) {
            try {
                //仓库已满,等待
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        //放入产品
        products[++index]="product";
        System.out.println(Thread.currentThread().getName()+"生产了一个产品,产品数量:"+(index+1));
        System.out.println(Arrays.toString(products));
        this.notifyAll();
    }
    //消费者消费
    public synchronized void Consumer() {
        while (index<0) {
            try {
                //仓库已空,等待
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        //取出产品
        products[index--]=null;
        System.out.println(Thread.currentThread().getName()+"消费了一个产品,产品数量:"+(index+1));
        System.out.println(Arrays.toString(products));
        this.notifyAll();
    }
}
public class Productor implements Runnable{
    ProductBuf productBuf=new ProductBuf();
    public Productor() {		
    }
    public Productor(ProductBuf productBuf) {
        this.productBuf=productBuf;
    }
    @Override
    public void run() {
        //生产30个产品
        for(int i=0;i<30;i++) {
            productBuf.Productor();
        }
    }
}
public class Consumer implements Runnable{
	ProductBuf productBuf=new ProductBuf();
	public Consumer() {
	}
	public Consumer(ProductBuf productBuf) {
		this.productBuf=productBuf;
	}
	@Override
	public void run() {
		//消费30个产品
		for(int i=0;i<30;i++) {
			productBuf.Consumer();
		}
	}
}
public class testProduct {
	public static void main(String[] args) {
		ProductBuf productBuf=new ProductBuf();
		Productor productor=new Productor(productBuf);
		Consumer consumer=new Consumer(productBuf);
		new Thread(productor,"生产者1号").start();
		new Thread(productor,"生产者2号").start();
		new Thread(consumer,"消费者1号").start();
		new Thread(consumer,"消费者2号").start();
	}
}

运行结果如下所示,没有问题:

生产者1号生产了一个产品,产品数量:1
[product, null, null, null, null, null]
消费者1号消费了一个产品,产品数量:0
[null, null, null, null, null, null]
生产者2号生产了一个产品,产品数量:1
[product, null, null, null, null, null]
生产者2号生产了一个产品,产品数量:2
[product, product, null, null, null, null]
生产者2号生产了一个产品,产品数量:3
[product, product, product, null, null, null]
生产者2号生产了一个产品,产品数量:4
[product, product, product, product, null, null]
生产者2号生产了一个产品,产品数量:5
[product, product, product, product, product, null]
生产者2号生产了一个产品,产品数量:6
[product, product, product, product, product, product]
消费者2号消费了一个产品,产品数量:5
[product, product, product, product, product, null]
......
消费者2号消费了一个产品,产品数量:1
[product, null, null, null, null, null]
消费者2号消费了一个产品,产品数量:0
[null, null, null, null, null, null]
生产者2号生产了一个产品,产品数量:1
[product, null, null, null, null, null]
消费者2号消费了一个产品,产品数量:0
[null, null, null, null, null, null]

6. 线程池

6.1 线程池概念

首先有关线程的使用会出现两个问题:

  1. 线程是宝贵的内存资源、单个线程约占1MB空间,过多分配易造成内存溢出。
  2. 频繁的创建及销毁线程会增加虚拟机回收频率、资源开销,造成性能下降。

基于如上的问题,出现了线程池:

  • 线程容器可设定线程分配的数量
  • 将预先创建的线程对象存入池中并重用线程池中的线程对象
  • 避免频繁的创建和销毁

6.2 线程池原理

假如线程池里固定有三个线程,有四个任务。线程池中的三个线程分别完成三个任务,第四个任务则进入等待状态,线程执行完前三个任务后继续执行第四个任务。

  • 将任务提交给线程池,由线程池分配线程、运行任务,并在当前任务结束后复用线程。

6.3 创建线程池

  • 常用的线程池接口的类(所在包java.util.concurrent
    • Executor:线程池的顶级接口。
    • ExecutorService:线程池接口,可通过submit(Runnable task)提交任务代码。
    • Executors工厂类:创建线程池的工具类。
      1. 创建固定线程个数的线程池。
      2. 创建缓存线程池,由任务的多少决定。
      3. 创建单线程池。
      4. 创建调度线程池。调度:周期、定时执行。
    • 通过newFixedThreadPool(int nThreads)获得固定数量的线程池。参数:指定线程池中线程的数量。
    • 通过newCachedThreadPool()获得动态数量的线程池,如不够则创建新的,没有上限。
/**
 * 演示线程池的创建(结果同银行卡案例,不再演示)
 */
public class Demo1 {
	public static void main(String[] args) {	
        //银行卡案例
		BandCard bandCard=new BandCard();
		AddMoney addMoney=new AddMoney(bandCard);
		SubMoney subMoney=new SubMoney(bandCard);
		
		//1.1创建固定线程个数的线程池
		//ExecutorService executorService=Executors.newFixedThreadPool(4);
		//1.2创建缓存线程池,线程个数由任务个数决定
		ExecutorService executorService=Executors.newCachedThreadPool();
		//1.3创建单线程线程池
		//Executors.newSingleThreadExecutor();
		//1.4创建调度线程池
		//Executors.newScheduledThreadPool(corePoolSize);
		//2.提交任务
		executorService.submit(addMoney);
		executorService.submit(subMoney);
		executorService.submit(addMoney);
		executorService.submit(subMoney);
		//等待所有已执行的任务执行完毕后关闭线程池,不再接受新任务
		executorService.shutdown();
		//会试图停止所有正在执行的任务
		//executorService.shutdownNow();
	}
}

6.4 Callable接口

public interface Callable<V>{
    public V call() throws Exception;
}
  • JDK1.5加入,与Runnable接口类似,实现之后代表一个线程任务。
  • Callable具有泛型返回值、可以声明异常。

与Runnable接口的区别

  1. Callable接口中call方法有返回值,Runnable接口中run方法没有返回值。
  2. Callable接口中call方法有声明异常,Runnable接口中run方法没有异常。
/**
 * 演示Callable接口的使用
 * 功能需求:使用Callable实现1-100的和。
 */
public class Demo {
	public static void main(String[] args) throws InterruptedException, ExecutionException {
		//1.创建Callable对象
		Callable<Integer> callable=new Callable<Integer>() {
			private int sum=0;
			@Override
			public Integer call() throws Exception {
				for(int i=1;i<=100;i++) {
					sum+=i;
				}
				return sum;
			}
		};
		//2.Thread的构造方法中没有带Callable的构造方法
        //需要把Callable对象转成可执行任务,FutureTask表示将要执行的任务
		//该类实现了RunnableFuture<V>接口,而该接口又继承了Runnable类
		FutureTask<Integer> task=new FutureTask<Integer>(callable);
		
		//3.创建线程对象
		Thread thread=new Thread(task);
		//4.启动线程
		thread.start();
		//5.获取结果(等待call方法执行完毕,才会返回)
		Integer sum=task.get();
		System.out.println("结果是"+sum);
	}
}

6.5 Callable结合线程池使用

/**
 * 使用线程池计算1-100的和
 */
public class Demo2 {
	public static void main(String[] args) throws InterruptedException, ExecutionException {
		//1.创建线程池
		ExecutorService executorService=Executors.newFixedThreadPool(1);
		//2.提交任务,Future表示将要执行任务的结果;
        //submit可以传入一个Callable<T>对象
		Future<Integer> future=executorService.submit(new Callable<Integer>() {
			private int sum=0;
			@Override
			public Integer call() throws Exception {
				System.out.println(Thread.currentThread().getName()+"开始计算。。");
				for(int i=1;i<=100;i++) {
					sum+=i;
					Thread.sleep(10);
				}
				return sum;
			}
		});
		//3.获取任务的结果(等待任务完成才会返回)
		System.out.println(future.get());
        //4.关闭线程池
		executorService.shutdown();
	}
}

6.6 Future接口

  • Future表示将要完成任务的结果

演示一个案例:使用两个线程,并发计算1-50、51-100的和,再进行汇总统计。

/**
 * 演示Future接口的使用
 */
public class Demo3 {
	public static void main(String[] args) throws InterruptedException, ExecutionException {
		//1.创建线程池
		ExecutorService executorService=Executors.newFixedThreadPool(2);
		//2.提交任务
		Future<Integer> future1=executorService.submit(new Callable<Integer>() {
			int sum=0;
			@Override
			//计算1-50的和
			public Integer call() throws Exception {
				for(int i=1;i<=50;i++) {
					sum+=i;
				}
				System.out.println("1-50的和计算完毕。");
				return sum;
			}
		});
		Future<Integer> future2=executorService.submit(new Callable<Integer>() {
			int sum=0;
			@Override
			//计算51-100的和
			public Integer call() throws Exception {
				for(int i=51;i<=100;i++) {
					sum+=i;
				}
				System.out.println("51-100的和计算完毕。");
				return sum;
			}
		});
		//3.获取结果
		System.out.println(future1.get()+future2.get());
		//4.关系线程池
		executorService.shutdown();
	}
}
  • 表示ExecutorService.submit()所返回的状态结果就是call的返回值
  • 方法V get()以阻塞形式等待Future中的异步处理结果call的返回值)。

6.7 线程的同步与异步

  • 同步

    形容一次方法调用,同步一旦开始,调用者必须等待该方法返回,才能继续。

    当主线程调用子线程执行任务时,必须等到子线程返回结果后才能继续。

  • 异步

    形容一次方法调用,异步一旦开始就像是一次消息传递,调用者告知之后立刻返回。二者竞争时间片,并发执行。异步有多条执行路径


7. Lock接口

  • JDK1.5加入,与synchronized比较,不仅显示定义,而且结构更灵活。
  • 提供了更多实用性方法,功能更强大、性能更优越。

常用方法:

  • void lock

    获取锁,如果锁被占用,当前线程则进入等待状态。

  • boolean tryLock()

    尝试获取锁(成功返回true,失败返回false,不阻塞)

  • void unlock()

    释放锁。

7.1 重入锁

  • ReentrantLock: Lock接口的实现类,与synchronized一样具有互斥锁功能。

    所谓重入锁,是指一个线程拿到该锁后还可以再次成功获取,而不会因为该锁已经被持有(尽管是自己所持有)而陷入等待状态(死锁)。之前说过的synchronized也是可重入锁

7.1.1 可重入锁解释及应用

为了具体解释什么叫可重入锁,这里重写5.4节死锁关于两个人拿筷子吃饭的案例:

public class Chopsticks {
	boolean getOne=false;
	boolean getAnother=false;
	//拿筷子,获取锁,该锁是当前Chopsticks对象
	public synchronized void getOne() {
		getOne=true;
		System.out.println(Thread.currentThread().getName()+"拿到了一根筷子。");
		//if语句块调用了另外的同步方法,需要再次获取锁,而该锁也是当前Chopsticks对象
		if(getAnother) {
			//有两根筷子,吃饭
			canEat();
			//吃完放下两根筷子
			getOne=false;
			getAnother=false;
		}else {
			//只有一根筷子,去拿另一根,然后吃饭
			getAnother();
		}		
	}
	public synchronized void getAnother() {
		getAnother=true;
		System.out.println(Thread.currentThread().getName()+"拿到了一根筷子。");
		if(getOne) {
			//有两根筷子,吃饭
			canEat();
			//吃完放下两根筷子
			getOne=false;
			getAnother=false;
		}else {
			//只有一根筷子,去拿另一根,然后吃饭
			getOne();
		}		
	}
	public synchronized void canEat() {
			System.out.println(Thread.currentThread().getName()+"拿到了两根筷子,开恰!");
	}
}

在这个筷子类中,拿第一根筷子的时候获取了一把锁,锁对象是this,也就是当前Chopsticks对象;拿第二根筷子的时候又获取了一次锁,锁对象是this,也是当前Chopsticks对象。测试类如下,说明在后面:

public class testChopstick {
	public static void main(String[] args) {
		Chopsticks chopsticks=new Chopsticks();
		//线程A,模拟人A
		Thread A=new Thread(new Runnable() {		
			@Override
			public void run() {
				chopsticks.getOne();
			}
		});
		//线程B,模拟人B
		Thread B=new Thread(new Runnable() {		
			@Override
			public void run() {
				chopsticks.getAnother();
			}
		});
		A.start();
		B.start();
	}
}

两个线程都执行的是同一个对象chopsticks中的方法,这两个同步方法在执行时将会获取同样的锁;当线程1抢到CUP进入getOne时获取锁然后执行代码,如果线程1还未执行完毕就被线程2抢占了CPU,当线程2进入getAnother时发现锁在线程1那里,于是线程2等待;线程1重新拿到CPU继续执行代码,进入getAnother方法获取锁,发现锁就在自己这里于是继续执行这就是可重入锁。可重入锁避免了死锁的发生,避免线程因获取不了锁而进入永久等待状态。

上述代码运行如下:

Thread-0拿到了一根筷子。
Thread-0拿到了一根筷子。
Thread-0拿到了两根筷子,开恰!
Thread-1拿到了一根筷子。
Thread-1拿到了一根筷子。
Thread-1拿到了两根筷子,开恰!

7.1.2 重入锁的使用

还是以2.5节卖票案例为例进行演示。

//重入锁的使用
public class Ticket implements Runnable{
    int ticket=100;
    //创建重入锁对象
    Lock lock=new ReentrantLock();
    @Override
    public void run() {	
        while(true) {	
            //上锁		
            lock.lock();
            try {					
                if(ticket>0)
                    System.out.println(Thread.currentThread().getName()+"卖出了一张票,还剩"+(--ticket)+"张。");
                else break;
            } finally {
                //解锁
                lock.unlock();
            }	
        }
    }
}

这里主要注意一下上锁后记得解锁,有几个lock就要有对应的几个unlock。

public class testTicket {
	public static void main(String[] args) {
		Ticket ticket=new Ticket();
		//1.创建线程池
		ExecutorService eService=Executors.newFixedThreadPool(4);
		//2.提交四次,让四个线程来运行
		for(int i=0;i<4;i++) {
			eService.submit(new Ticket());
		}
		eService.shutdown();
	}
}

运行结果不再演示。

7.2 读写锁

ReentrantReadWriteLock:

  • 一种支持一写多读的同步锁,读写分离,可以分别分配读锁和写锁。
  • 支持多次分配读锁,使多个读操作可以并发执行。

互斥规则:

  • 写----写:互斥,一个线程在写的同时其他线程会被阻塞。
  • 读----写:互斥,读的时候不能写,写的时候不能读。
  • 读----读:不互斥、不阻塞。
  • 在读操作远远高于写操作的环境中,可在保证线程安全的情况下,提高运行效率。
//演示读写锁的使用
public class ReadWriteLock {
		//创建读写锁对象
		ReentrantReadWriteLock rrlLock=new ReentrantReadWriteLock();
		ReadLock readLock=rrlLock.readLock();//获得读锁
		WriteLock writeLock=rrlLock.writeLock();//获得写锁
		private int value=999;
		//读方法
		public int getValue() {
			readLock.lock();//开启读锁
			try {
				try {
					Thread.sleep(500);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				return this.value;
			} finally {
				readLock.unlock();//释放读锁
			}			
		}
		//写方法
		public void setValue(int value) {
			writeLock.lock();//开启写锁
			try {
				try {
					Thread.sleep(500);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				this.value=value;
			} finally {
				writeLock.unlock();//释放写锁
			}	
		}
}
public class testReadWriteLock {
	public static void main(String[] args) {
		ExecutorService eService=Executors.newFixedThreadPool(20);
		ReadWriteLock rwlLock=new ReadWriteLock();
		Runnable read=new Runnable() {		
			@Override
			public void run() {
				System.out.println(rwlLock.getValue());
			}
		};
		Runnable write=new Runnable() {		
			@Override
			public void run() {
				rwlLock.setValue(666);
				System.out.println("改写为666");
			}
		};
		//写2次
		for(int i=0;i<2;i++) {
			eService.submit(write);
		}
		//读18次
		for(int i=0;i<18;i++) {
			eService.submit(read);
		}	
		eService.shutdown();
	}
}

通过调用sleep可以观察到,只有在读写交替和两个写操作的时候程序是互斥执行,而在读操作时线程之间是并发执行。


8. 线程安全的集合

下图中蓝色的表示线程安全的集合,绿色表示现代开发中已经很少使用的线程安全的集合。

  • Collection体系集合

  • Map安全集合体系

在多线程中使用线程不安全的集合会出现异常。在JDK1.5之前,可以使用Collections中的工具类方法。

Collections工具类中提供了多个可以获得线程安全集合的方法:

  • public static <T> Collection<T> synchronizedCollection(Collection<T> c)
  • public static <T> List<T> synchronizedList(List<T> list)
  • public static <T> Set<T> synchronizedSet(Set<T> s)
  • public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m)
  • public static <T> SortedSet<T> synchronizedSortedSet(SortedSet<T> s)
  • public static <K,V> SortedMap<K,V> synchronizedSortedMap(SortedMap<K,V>)

以上为JDK1.2提供,接口单一、维护性高,但性能没有提升,均以synchronized实现。

public class Demo1 {
	public static void main(String[] args) {
        //1.使用ArrayList(不经过2步骤则报异常)
        ArrayList<String> arrayList=new ArrayList<String>();

        //2 使用Collections中的线程安全方法转成线程安全的集合
        List<String> synList=Collections.synchronizedList(arrayList);
        //3 使用并发包里提供的集合
        //CopyOnWriteArrayList<String> arrayList2=new CopyOnWriteArrayList<String>();

        //创建线程
        for(int i=0;i<20;i++) {
            int temp=i;
            new Thread(new Runnable() {			
                @Override
                public void run() {
                    for(int j=0;j<10;j++) {
                        synList.add(Thread.currentThread().getName()+":"+temp);
                        System.out.println(synList.toString());
                    }
                }
            }).start();
        }
    }
}

把ArrayList转成线程安全的集合后程序正常运行,结果不再演示。

8.1 CopyOnWriteArrayList集合

  • 线程安全的ArrayList,加强版的读写分离。
  • 写有锁,读无锁,读写之间不堵塞,优于读写锁。
  • 写入时,先copy一个容器副本、再添加新元素,最后替换引用。所以说它是用空间换安全的一种方式。
  • 使用ArrayList无异。
/**
 * 演示CopyOnWriteArrayList的使用
 */
public class Demo2 {
    public static void main(String[] args) {
        //1.创建集合
        CopyOnWriteArrayList<String> list=new CopyOnWriteArrayList<String>();
        //2.使用多线程操作
        ExecutorService eService=Executors.newFixedThreadPool(5);
        //3.提交任务
        for(int i=0;i<5;i++) {
            eService.submit(new Runnable() {			
                @Override
                public void run() {
                    for(int j=0;j<10;j++) {
                        list.add(Thread.currentThread().getName()+"..."+new Random().nextInt(1000));
                    }
                }
            });			
        }
        //4.关闭线程池
        eService.shutdown();
        //等所有线程都执行完毕
        while(!eService.isTerminated());
        //5.打印结果
        System.out.println("元素个数:"+list.size());
        for (String string : list) {
            System.out.println(string);
        }
    }
}

结果如下,没有问题:

元素个数:50
pool-1-thread-2...222
pool-1-thread-2...688
pool-1-thread-2...770
    ......
pool-1-thread-4...568
pool-1-thread-4...537
pool-1-thread-4...413

8.2 CopyOnWriteArrayList源码分析

  • final transient ReentrantLock lock = new ReentrantLock();

    此集合所使用的的锁lock是重入锁ReentrantLock。

  • private transient volatile Object[] array;

    此集合实际存储的数组array。

  • 在上节中调用的无参构造方法创建的是一个空的数组。

    public CopyOnWriteArrayList() {
        setArray(new Object[0]);
    }
    
    final void setArray(Object[] a) {
        array = a;
    }
    
  • add(E)添加元素是先把原来的数组copy到一个长度加1的新数组里,然后对新数组进行操作,最后再把新数组赋给原数组。这个操作上了锁。

    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }
    
  • remove(int)删除元素同样是复制原数组到一个长度减1的新数组里,然后对新数组进行操作,最后再把新数组赋给原数组。这个操作也上了锁。

    public E remove(int index) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            E oldValue = get(elements, index);
            int numMoved = len - index - 1;
            if (numMoved == 0)
                setArray(Arrays.copyOf(elements, len - 1));
            else {
                Object[] newElements = new Object[len - 1];
                System.arraycopy(elements, 0, newElements, 0, index);
                System.arraycopy(elements, index + 1, newElements, index,
                                 numMoved);
                setArray(newElements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }
    

    有关数组修改的操作都上了锁,也就说写操作是互斥访问的。

    有关读操作的代码都是直接进行了访问,没有上锁,也就是说在写的同时可以读。

    private E get(Object[] a, int index) {
        return (E) a[index];
    }
    

8.3 CopyOnWriteArraySet集合

  • 线程安全的Set,底层使用CopyOnWriteArrayList实现。

  • 唯一不同在于,使用addIfAbsent()添加元素,会遍历数组,如果已有元素(比较依据是equals),则不添加(扔掉副本)。

    //演示CopyOnWriteArraySet的使用
    public class Demo3 {
    	public static void main(String[] args) {
    		CopyOnWriteArraySet<String> set=new CopyOnWriteArraySet<String>();
    		set.add("tang");
    		set.add("he");
    		set.add("yu");
    		set.add("wang");
    		set.add("tang");//重复元素,添加失败
    		System.out.println(set.size());
    		System.out.println(set.toString());
    	}
    }
    

    这个set集合是顺序输出的,结果如下:

    4
    [tang, he, yu, wang]
    

8.4 CopyOnWriteArraySet源码分析

  • private final CopyOnWriteArrayList<E> al

    这个集合实际上使用的就是CopyOnWriteArrayList集合。

  • 它的无参构造方法new的就是CopyOnWriteArrayList对象,所以它是有序的。

    public CopyOnWriteArraySet() {
        al = new CopyOnWriteArrayList<E>();
    }
    
  • 添加元素的操作和CopyOnWriteArrayList大同小异。

    public boolean add(E e) {
        return al.addIfAbsent(e);
    }
    
    public boolean addIfAbsent(E e) {
        Object[] snapshot = getArray();
        return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false :
        addIfAbsent(e, snapshot);
    }
    

    这是一个三元表达式,意思是存在相同元素返回false,否则添加元素。

    先进入indexOf方法查看源码:

    private static int indexOf(Object o, Object[] elements,
                               int index, int fence) {
        if (o == null) {
            for (int i = index; i < fence; i++)
                if (elements[i] == null)
                    return i;
        } else {
            for (int i = index; i < fence; i++)
                if (o.equals(elements[i]))
                    return i;
        }
        return -1;
    }
    

    add方法是添加单个元素,index参数就是0,这个方法就是在遍历数组,如果数组中已经存在相同元素则返回数组下标,注意看它的比较依据是equals方法;如果不存在则返回-1。

    addIfAbsent所返回的三元表达式中,如果indexOf方法返回数组下标,则返回false,表示已经存在相同元素,添加失败;否则返回-1执行addIfAbsent(e, snapshot),进入该方法:

    private boolean addIfAbsent(E e, Object[] snapshot) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] current = getArray();
            int len = current.length;
            if (snapshot != current) {
                // Optimize for lost race to another addXXX operation
                int common = Math.min(snapshot.length, len);
                for (int i = 0; i < common; i++)
                    if (current[i] != snapshot[i] && eq(e, current[i]))
                        return false;
                if (indexOf(e, current, common, len) >= 0)
                    return false;
            }
            Object[] newElements = Arrays.copyOf(current, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }
    

    我们可以忽略if语句,重点关注它的添加操作,发现它也将原数组Copy到长度加一的新数组中,再对新数组进行操作,这个写操作上了锁。其他的写方法都调用了CopyOnWriteArrayList的方法,同样是写操作上锁,读操作可以同时执行。

9. Queue接口(队列)

Collection的子接口,表示队列FIFO(First In First Out),先进先出。

常用方法:

  • 抛出异常:

    • boolean add(E e)

      顺序添加一个元素(到达上限后,再添加则会抛出异常)。

    • E remove()

      获得第一个元素并移除(如果队列没有元素时,则抛出异常)。

    • E element()

      获得第一个元素但不移除(如果队列没有元素时,则抛异常)。

  • 返回特殊值:(建议使用以下方法

    • boolean offer(E e)

      顺序添加一个元素(到达上限后,再添加则会返回false)。

    • E poll()

      获得第一个元素并移除(如果队列没有元素时,则返回null)。

    • E peek()

      获得第一个元素但不移除(如果队列没有元素时,则返回null)。

//演示Queue实现类的使用
public class Demo4 {
	public static void main(String[] args) {
		//创建队列
		Queue<String> queue=new LinkedList<String>();
		//入队
		queue.offer("tang");
		queue.offer("he");
		queue.offer("yu");
		queue.offer("wang");
		queue.offer("fan");
		System.out.println("队首元素:"+queue.peek());
		System.out.println("元素个数:"+queue.size());
		//出队
		int size=queue.size();
		for(int i=0;i<size;i++) {
			System.out.println(queue.poll());
		}
		System.out.println("出队完毕:"+queue.size());
	}
}

需要注意的是因为LinkedList是线程不安全的集合,所以不能在多线程的环境中使用。该程序输出如下:

队首元素:tang
元素个数:5
tang
he
yu
wang
fan
出队完毕:0

9.1 ConcurrentLinkedQueue类

  • Queue接口的实现类。线程安全、可高效读写的队列,高并发下性能最好的队列。

  • 无锁、CAS(Compare and Swap)比较交换算法,修改的方法包含三个核心参数(V,E,N)。

  • V:要更新的变量;E:预期值;N:新值。

  • 只有当V==E,V=N;否则表示V已被更新过,则取消当前操作。

    也就是说假如当前值V是80,要将其改成100,先将V读取出来,读取的V就是预期值;如果预期值E和V相等,就把V的值更新成新值100;如果不等,说明中间有其他线程更新了V,就取消当前操作。

//演示线程安全的队列
public class Demo5 {
	public static void main(String[] args) throws InterruptedException {
		//创建安全队列
		ConcurrentLinkedQueue<Integer> queue=new ConcurrentLinkedQueue<Integer>();
		//两个线程执行入队操作
		Thread t1=new Thread(new Runnable() {		
			@Override
			public void run() {
				for(int i=1;i<=5;i++) {
					queue.offer(i);
				}
			}
		});
		Thread t2=new Thread(new Runnable() {		
			@Override
			public void run() {
				for(int i=6;i<=10;i++) {
					queue.offer(i);
				}
			}
		});
		//启动线程
		t1.start();
		t2.start();
		t1.join();
		t2.join();
		for(int i=1;i<=10;i++) {
			System.out.println(queue.poll());
		}
	}
}

因为是两个线程同时添加,所以结果不是顺序的:

1
6
2
3
4
7
5
8
9
10

9.2 BlockingQueue接口(阻塞队列)

  • Queue的子接口,阻塞的队列,增加了两个线程状态为无限期等待的方法。

  • 方法

    • void put(E e)

      将指定元素插入此队列中,如果没有可用空间,则等待。

    • E take()

      获取并移除此队列头部元素,如果没有可用元素,则等待。

  • 可用于解决生产者消费者问题

9.2.1 阻塞队列(实现类)

  • ArrayBlockingQueue

    数组结构实现,有界队列。

  • LinkedBlockingQueue

    链表结构实现,有界队列。默认上限Integer.MAX_VALUE

通过一个小程序演示一下所谓的阻塞:

public class Demo6 {
	public static void main(String[] args) throws InterruptedException {
		//创建一个有界队列
		ArrayBlockingQueue<Integer> arrayBlockingQueue=new ArrayBlockingQueue<Integer>(3);
		//添加数据使用put
		arrayBlockingQueue.put(1);
		arrayBlockingQueue.put(2);
		arrayBlockingQueue.put(3);
		System.out.println(arrayBlockingQueue.size());
		System.out.println(arrayBlockingQueue.toString());
		arrayBlockingQueue.put(4);
		System.out.println("我不会被执行。");
	}
}

该程序执行后可以通过控制台看见程序并没有结束,也没有打印最后一句话,说明当前线程(主线程)被阻塞了:

3
[1, 2, 3]

9.2.2 重写生产者消费者问题

public class Demo7 {
	public static void main(String[] args) {
		//创建队列
		ArrayBlockingQueue<Integer> queue=new ArrayBlockingQueue<Integer>(6);
		//创建两个线程
		Thread t1=new Thread(new Runnable() {		
			@Override
			public void run() {
				for(int i=1;i<=30;i++) {
					try {
						queue.put(i);
						System.out.println("生产者生产了一个产品,产品ID:"+i);
					} catch (InterruptedException e) {
						e.printStackTrace();
					}				
				}
			}
		});
		Thread t2=new Thread(new Runnable() {		
			@Override
			public void run() {
				for(int i=1;i<=30;i++) {
					try {
						queue.take();
						System.out.println("消费者消费了一个产品,产品ID:"+i);
					} catch (InterruptedException e) {
						// TODO 自动生成的 catch 块
						e.printStackTrace();
					}					
				}
			}
		});
		t1.start();
		t2.start();
	}
}

需要注意的是插入队尾的方法是put,删除队首元素的方法是take。结果运行如下:

生产者生产了一个产品,产品ID:1
生产者生产了一个产品,产品ID:2
生产者生产了一个产品,产品ID:3
生产者生产了一个产品,产品ID:4
生产者生产了一个产品,产品ID:5
生产者生产了一个产品,产品ID:6
消费者消费了一个产品,产品ID:1
    ......
消费者消费了一个产品,产品ID:25
消费者消费了一个产品,产品ID:26
消费者消费了一个产品,产品ID:27
消费者消费了一个产品,产品ID:28
消费者消费了一个产品,产品ID:29
消费者消费了一个产品,产品ID:30

10. ConcurrentHashMap

  • 初始容量默认为16段(Segment),使用分段锁设计。每一段都对应着一个哈希表。
  • 不对整个Map加锁,而是为每个Segment加锁。对一个Segment的操作不影响其他Segment。
  • 当多个对象存入同一个Segment时,才需要互斥。
  • 最理想状态为16个对象分别存入16个Segment,并行数量16。
  • 使用方式与HashMap无异。

注:在JDK1.8之后,ConcurrentHashMap不再采用分段锁,而是采用无锁算法CAS。

//演示线程安全的Map
public class Demo8 {
	public static void main(String[] args) {
		//创建集合
		ConcurrentHashMap<String, Integer> hashMap=new ConcurrentHashMap<String, Integer>();
		//使用多线程添加数据
		for(int i=0;i<5;i++) {
			new Thread(new Runnable() {				
				@Override
				public void run() {
					for(int k=0;k<10;k++) {
						hashMap.put(Thread.currentThread().getName(), k);
						System.out.println(hashMap);
					}
				}
			}).start();
		}
	}
}

没有问题,不再演示结果。

这篇博客真的好长,但想着分成几篇又不方便回顾。

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值