Java多线程学习笔记
一、概述
多线程听上去是非常专业的概念,其实非常简单——单线程的程序(前面介绍的绝大部分程序)只有一个顺序执行流,多线程的程序则可以包括多个顺序执行流,多个顺序流之间互不干扰。
一般而言,进程包含如下3个特征。
独立性:进程是系统中独立存在的实体,它可以拥有自己独立的资源,每一个进程都拥有自己私有的地址空间。在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间。
动态性:进程与程序的区别在于,程序只是一个静态的指令集合,而进程是一个正在系统中活动的指令集合。在进程中加入了时间的概念。进程具有自己的生命周期和各种不同的状态,这些概念在程序中都是不具备的。
并发性:多个进程可以在单个处理器上并发执行,多个进程之间不会互相影响。
并发性(concurrency)和并行性(parallel)是两个概念,并行指在同一时刻,有多条指令在多个处理器上同时执行;并发指在同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得在宏观上具有多个进程同时执行的效果。
多线程则扩展了多进程的概念,使得同一个进程可以同时并发处理多个任务。线程(Thread)也被称作轻量级进程(Lightweight Process),线程是进程的执行单元。就像进程在操作系统中的地位一样,线程在程序中是独立的、并发的执行流。当进程被初始化后,主线程就被创建了。对于绝大多数的应用程序来说,通常仅要求有一个主线程,但也可以在该进程内创建多条顺序执行流,这些顺序执行流就是线程,每个线程也是互相独立的。
线程是进程的组成部分,一个进程可以拥有多个线程,一个线程必须有一个父进程。线程可以拥有自己的堆栈、自己的程序计数器和自己的局部变量,但不拥有系统资源,它与父进程的其他线程共享该进程所拥有的全部资源。因为多个线程共享父进程里的全部资源,因此编程更加方便;但必须更加小心,我们必须确保线程不会妨碍同一进程里的其他线程。
多线程的优势
进程之间不能共享内存,但线程之间共享内存非常容易。
系统创建进程时需要为该进程重新分配系统资源,但创建线程则代价小得多,因此使用多线程来实现多任务并发比多进程的效率高。
Java语言内置了多线程功能支持,而不是单纯地作为底层操作系统的调度方式,从而简化了Java的多线程编程。
二、线程的创建与使用
通过Thread类创建线程类
使用继承Thread类的方法来创建线程类时,多个线程之间无法共享线程类的实例变量。
public class test extends Thread {
private int i;
public void run(){
for (;i<100;i++){
//Thread
// Thread对象课直接调用getName()方法获取当i去哪线程名字
System.out.println(getName()+" "+i);
}
}
public static void main(String[] args) throws Exception{
for (int i = 0; i <100 ; i++) {
System.out.println(Thread.currentThread().getName()+" "+i);
if (i==20){
test t=new test();
new test().start();
new test().start();
}
}
}
}
虽然上面程序只显式地创建并启动了2个线程,但实际上程序有3个线程,即程序显式创建的2个子线程和主线程。
实现Runnable接口创建线程类
Runnable对象仅仅作为Thread对象的target,Runnable实现类里包含的run()方法仅作为线程执行体。而实际的线程对象依然是Thread实例,只是该Thread线程负责执行其target的run()方法
public class test implements Runnable {
private int i;
public void run(){
for (;i<100;i++){
//当时先Runnable接口时获取当前线程时只能用Thread.currentThread().getName()方法
System.out.println(Thread.currentThread().getName()+" "+i);
}
}
public static void main(String[] args) throws Exception{
for (int i = 0; i <100 ; i++) {
System.out.println(Thread.currentThread().getName()+" "+i);
if (i==20){
test t=new test();
new Thread(t,"新线程1").start();
new Thread(t,"新线程2").start();
}
}
}
}
通过继承Thread类来获得当前线程对象比较简单,直接使用this就可以了;但通过实现Runnable接口来获得当前线程对象,则必须使用Thread.currentThread()方法。
程序所创建的Runnable对象只是线程的target,而多个线程可以共享同一个target,所以多个线程可以共享同一个线程类(实际上应该是线程的target类)的实例属性。
使用Callable和Future创建线程
实现Runnable接口创建多线程时,Thread类的作用就是把run()方法包装成线程执行体。那么是否可以直接把任意方法都包装成线程执行体呢?Java目前不行!但C#可以(C#可以把任意方法包装成线程执行体,包括有返回值的方法)。
从Java 5开始,Java提供了Callable接口,该接口怎么看都像是Runnable接口的增强版,Callable接口提供了一个call()方法可以作为线程执行体,但call()方法比run()方法功能更强大。
call()方法可以有返回值。 call()方法可以声明抛出异常。
Java 5提供了Future接口来代表Callable接口里call()方法的返回值,并为Future接口提供了一个FutureTask实现类,该实现类实现了Future接口,并实现了Runnable接口——可以作为Thread类的target。
在Future接口里定义了如下几个公共方法来控制它关联的Callable任务。
boolean cancel(boolean mayInterruptIfRunning):试图取消该Future里关联的Callable任务。
V get():返回Callable任务里call()方法的返回值。调用该方法将导致程序阻塞,必须等到子线程结束后才会得到返回值。
V get(long timeout, TimeUnit unit):返回Callable任务里call()方法的返回值。该方法让程序最多阻塞timeout和unit指定的时间,如果经过指定时间后Callable任务依然没有返回值,将会抛出TimeoutException异常。
boolean isCancelled():如果在Callable任务正常完成前被取消,则返回true。
boolean isDone():如果Callable任务已完成,则返回true。
Callable接口有泛型限制,Callable接口里的泛型形参类型与call()方法返回值类型相同。
public class test implements Callable {
//实现call()方法,作为线程执行体
public Integer call() throws Exception {
int i=0;
for (;i<100;i++){
System.out.println(Thread.currentThread().getName()+" "+i);
}
return i;
}
public static void main(String[] args) throws Exception{
//创建Callable对象
test t=new test();
//使用FutureTask来包装Callable对象
FutureTask task=new FutureTask<>(t);
for (int i = 0; i <100 ; i++) {
System.out.println(Thread.currentThread().getName()+" "+i);
if (i==20){
//实质还是以Callable对象来创建并启动线程
new Thread(task,"有返回值的线程").start();
}
}
try {
System.out.println("子线程的返回值:"+task.get());
}catch (Exception e){
e.printStackTrace();
}
}
}
创建线程三种方式对比
采用实现Runnable、Callable接口的方式创建多线程——
线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。
在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
劣势是:编程稍稍复杂,如果需要访问当前线程,则必须使用Thread.currentThread()方法。
采用继承Thread类的方式创建多线程——
劣势是:因为线程类已经继承了Thread类,所以不能再继承其他父类。
优势是:编写简单,如果需要访问当前线程,则无须使用Thread.currentThread()方法,直接使用this即可获得当前线程。
三、线程的生命周期
当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态,在线程的生命周期中,它要经过新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead) 5种状态。
新建和就绪
当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的Java对象一样,仅仅由Java虚拟机为其分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。
当线程对象调用了start()方法之后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于JVM里线程调度器的调度。
PS:启动线程调用的是start()方法,而不是run()方法,永远不要调用run方法,调用start方法来启动线程,系统会将run方法当成线程执行体来处理,如果直接调用run方法,系统会将线程对象当作普通对象,run也是一个普通放法,而不是线程执行体。
只能对处于新建状态的线程调用start()方法,否则将引发IllegalThreadStateException异常。
如果希望调用子线程的start方法后,子线程立即执行,可以使用Thread.sleep(1)
来让当前线程(主线程)休眠一毫秒,因为cpu不会空闲,所以即可立即运行。
运行和阻塞
如果处于就绪状态的线程获得了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态,如果计算机只有一个CPU,那么在任何时刻只有一个线程处于运行状态。当然,在一个多处理器的机器上,将会有多个线程并行(注意是并行:parallel)执行;当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象。
当一个线程开始运行时,他不可能一直处于运行状态,线程在运行过程中需要被中断让其他线程获得执行的机会,线程调节的细节取决于底层平台的策略。
采用抢占式策略的系统,会给每一个可执行的线程一个时间段来处理任务,时间段用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。在选择下一个线程时,系统会考虑线程的优先级。现代桌面和服务器操作系统都采用抢占式调度策略。
一些小型设备如手机可能会采用协作式调度策略,只有当一个线程调用了它的sleep()或yield()方法后才会放弃所占用的资源——也就是必须由该线程主动放弃所占用的资源。当发生如下情况时,线程将会进入阻塞状态。
当前正在执行的线程被阻塞之后,其他线程就可以获得执行的机会。被阻塞的线程会在合适的时候重新进入就绪状态,(注意是就绪状态而不是运行状态)。也就是说,被阻塞线程的阻塞解除后,必须重新等待线程调度器再次调度它。
进入阻塞状态:
- 调用了sleep方法主动放弃占用的处理器资源
- 调用了一个阻塞式IO方法,返回前线程被阻塞
- 线程试图获得同步监视器,但正在被其他线程所持有
- 线程正在等待通知
- 程序调用了线程的suspend()方法将该线程挂起。但这个方法容易导致死锁,所以应该尽量避免使用该方法。
重新进入就绪状态:
- sleep方法过了指定时间
- 阻塞式IO方法已经返回
- 线程成功的获得了同步监视器
- 线程正在等待某通知,其他线程发出了一个通知
- 处于挂起状态的线程调用了resume恢复方法
PS:线程从阻塞状态只能进入就绪状态,无法直接进入运行状态。而就绪和运行状态之间的转换通常不受程序控制,而是由系统线程调度所决定,当处于就绪状态的线程获得处理器资源时,该线程进入运行状态;当处于运行状态的线程失去处理器资源时,该线程进入就绪状态。但有一个方法例外,调用yield()方法可以让运行状态的线程转入就绪状态。
线程死亡
线程以以下三种方式结束,结束后进入死亡状态
run()或call()方法执行完成,线程正常结束
线程抛出一个未捕获的Exception或Error
直接调用该线程的stop()方法来结束线程。该方法容易导致死锁,通常不推荐使用。
当主线程结束时,其他线程不受任何影响,并不会随之结束。一旦子线程启动起来后,它就拥有和主线程相同的地位,它不会受主线程的影响。不要试图对一个已经死亡的线程调用start()方法使它重新启动,死亡就是死亡,该线程将不可再次作为线程执行。
为了测试某个线程是否已经死亡,可以调用线程对象的isAlive()方法,当线程处于就绪、运行、阻塞3种状态时,该方法将返回true;当线程处于新建、死亡2种状态时,该方法将返回false。
public class StartDead extends Thread{
private int i;
public void run(){//线程执行体
for(;i<100;i++){
System.out.println(getName()+" "+i);
}
}
public static void main(String [] args){
//创建线程
StartDead sd = new StartDead();
for(int i =0;i<300;i++){
System.out.println(Thread.currentThread().getName()+" "+i);
if(i==20){
sd.start();//启动线程
System.out.println(sd.isAlive());//true
}
if(i>20 && !sd.isAlive()){
//再次启动该线程
sd.start();//死亡状态下线程无法再次运行
}
}
}
}
四、线程的控制
Java的线程支持提供了一些便捷的工具方法,通过这些便捷的工具方法可以很好地控制线程的执行。
join线程
Thread提供了让一个线程等待另一个线程完成的方法——join()方法。当在某个程序执行流中调用其他线程的join()方法时,调用线程将被阻塞,直到被join()方法加入的join线程执行完为止。join()方法通常由使用线程的程序调用,以将大问题划分成许多小问题,每个小问题分配一个线程。当所有的小问题都得到处理后,再调用主线程来进一步操作。
join有以下三种形式重载:
join()
join(long millis):等待被join的线程的时间最长为millis毫秒。如果在millis毫秒内被join的线程还没有执行结束,则不再等待。
join(long millis , int nanos):等待被join的线程的时间最长为millis毫秒加nanos毫微秒。
public class JoibThread extends Thread{
//提供一个有参数的构造器,用于设置线程名字
public JoinThread(String name){
super(name);
}
//重写run,定义线程执行体
public void run(){
for(int i=0;i<100;i++){
System.out.println(getName()+" "+i);
}
}
public static void main(String [] args) throws Exception{
new JoinThread("新线程").start();
for(int i=0;i<100;i++){
if(i==20){
JoinThread jt =new JoinThread("被join的线程");
jt.start();
//main线程调用了jt线程的join方法,
//main线程必须等待jt执行结束后才能向下执行
jt.join();
}
System.out.println(Thread.currentThread().getName()+" "+i);
}
}
}
上面程序中一共有3个线程,主方法开始时就启动了名为“新线程”的子线程,该子线程将会和main线程并发执行。当主线程的循环变量i等于20时,启动了名为“被Join的线程”的线程,该线程不会和main线程并发执行,main线程必须等该线程执行结束后才可以向下执行。在名为“被Join的线程”的线程执行时,实际上只有2个子线程并发执行,而主线程处于等待状态。
后台线程
有一种线程,它是在后台运行的,它的任务是为其他的线程提供服务,这种线程被称为“后台线程(Daemon Thread)”,又称为“守护线程”或“精灵线程”。JVM的垃圾回收线程就是典型的后台线程。
后台线程有个特征:如果所有的前台线程都死亡,后台线程会自动死亡。
调用Thread对象的setDaemon(true)方法可将指定线程设置成后台线程。setDaemon(true)必须在start()方法之前调用,否则会引发IllegalThreadStateException异常。
Thread类还提供了一个isDaemon()方法,用于判断指定线程是否为后台线程。
public class DaemonThread extends Thread{
public void run(){
for(int i=0;i<100;i++){
System.out.println(getName()+" "+i);
}
}
public static void main(String [] args){
DaemonThread t =new DaemonThread();
t.setDaemon(true);//设置为后台进程
t.start();
for(int i=0;i<10;i++){
System.out.println(Thread.currentThread().getName()+" "+i);
}
//程序到此处前台线程结束(main)
//后台进程也随之结束
}
}
当所有的前台线程死亡时,后台线程随之死亡。当整个虚拟机中只剩下后台线程时,程序就没有继续运行的必要了,所以虚拟机也就退出了。
本来该线程应该执行到i等于999时才会结束,但运行程序时不难发现该后台线程无法运行到999,因为当主线程也就是程序中唯一的前台线程运行结束后,JVM会主动退出,因而后台线程也就被结束了。
sleep线程睡眠
如果想让当前正在执行的线程暂停一段时间进入阻塞状态,则可以使用sleep()方法来实现。sleep有两种重载。
static void sleep(long millis):让当前正在执行的线程暂停millis毫秒,并进入阻塞状态,该方法收到系统计时器和线程调度器的精度与准确度的影响。
static void sleep(long millis,int nanos):让当前正在执行的线程暂停millis毫秒+nanos微秒,并进入阻塞状态,该方法收到系统计时器和线程调度器的精度与准确度的影响
public class SleepTest{
public static void main(String args) throws Exception{
for(int i=0;i<10;i++){
System.out.println("当前时间"+new Date());
Thread.sleep(1000);//暂停1s
}
}
}
当前线程调用sleep()方法进入阻塞状态后,在其睡眠时间段内,该线程不会获得执行的机会,即使系统中没有其他可执行的线程,处于sleep()中的线程也不会执行,因此sleep()方法常用来暂停程序的执行。
yield线程让步
yield()方法是一个和sleep()方法有点相似的方法,它也是Thread类提供的一个静态方法,它也可以让当前正在执行的线程暂停,但它不会阻塞该线程,它只是将该线程转入就绪状态。yield()只是让当前线程暂停一下,让系统的线程调度器重新调度一次,完全可能的情况是:当某个线程调用了yield()方法暂停之后,线程调度器又将其调度出来重新执行。
public class YieldTest extends Thread{
//提供一个有参数的构造器,用于设置线程名字
public YieldTest(String name){
super(name);
}
//重写run,定义线程执行体
public void run(){
for(int i=0;i<100;i++){
System.out.println(getName()+" "+i);
//i=10时,使用yield方法让当前线程让步
if(i==10){
Thread.yield();
}
}
}
public static void main(String [] args) throws Exception{
//将yt设置为最高优先级
YieldTest yt=new YieldTest("高级别");
yt.setPriority(Thread.MAX_PRIORITY);//1
yt.start();
//将yt设置为最低优先级
YieldTest yt1=new YieldTest("低级别");
yt1.setPriority(Thread.MIN_PRIORITY);//2
yt1.start();
}
}
}
上面程序中的调用yield()静态方法让当前正在执行的线程暂停,让系统线程调度器重新调度。将程序中1、2代码加上注态——使两个线程的优先级完全一样,所以当一个线程使用yield()方法暂停后,另一个线程就会开始执行。如果将1、2处代码的注释取消,也就是为两个线程分别设置不同的优先级,那么高优先级调用yield方法暂停后,没有与之相同的优先级,所以继续执行。
sleep和yeild方法的区别
- sleep()方法暂停当前线程后,会给其他线程执行机会,不会理会其他线程的优先级;但yield()方法只会给优先级相同,或优先级更高的线程执行机会。
- sleep()方法会将线程转入阻塞状态,直到经过阻塞时间才会转入就绪状态;而yield()不会将线程转入阻塞状态,它只是强制当前线程进入就绪状态。因此完全有可能某个线程调用yield()方法暂停之后,立即再次获得处理器资源被执行。
- sleep()方法声明抛出了InterruptedException异常,所以调用sleep()方法时要么捕捉该异常,要么显式声明抛出该异常;而yield()方法则没有声明抛出任何异常。
- sleep()方法比yield()方法有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行。
改变线程优先级
每个线程执行时都具有一定的优先级,优先级高的线程获得较多的执行机会,而优先级低的线程则获得较少的执行机会。
每个线程默认的优先级都与创建它的父线程的优先级相同,在默认情况下,main线程具有普通优先级,由main线程创建的子线程也具有普通优先级。
Thread类提供了setPriority(int newPriority)、getPriority()方法来设置和返回指定线程的优先级,其中setPriority()方法的参数可以是一个整数,范围是1~10之间,也可以使用Thread类的如下3个静态常量:
- MAX_PRIORITY:值为10
- MIN_PRIORITY:值为1
- NORM_PRIORITY:值为5
值得指出的是,虽然Java提供了10个优先级级别,但这些优先级级别需要操作系统的支持。遗憾的是,不同操作系统上的优先级并不相同,而且也不能很好地和Java的10个优先级对应,例如Windows 2000仅提供了7个优先级。在这种情况下,我们应该尽量避免直接为线程指定优先级,而应该使用MAX_PRIORITY、MIN_PRIORITY和NORM_PRIORITY三个静态常量来设置优先级,这样才可以保证程序具有最好的可移植性。
五、线程的同步
当使用多个线程来访问同一个数据时,很容易“偶然”出现线程安全问题
线程安全问题
关于线程安全问题,有一个经典的问题——银行取钱的问题。银行取钱的基本流程基本上可以分为如下几个步骤。
(1)用户输入账户、密码,系统判断用户的账户、密码是否匹配。
(2)用户输入取款金额。
(3)系统判断账户余额是否大于取款金额。
(4)如果余额大于取款金额,则取款成功;
如果余额小于取款金额,则取款失败。这个流程没有任何问题。但一旦将这个流程放在多线程并发的场景下,就有可能出现问题。注意此处说的是有可能,并不是说一定。也许你的程序运行了一百万次都没有出现问题,但没有出现问题并不等于没有问题!
下面来模拟两个人使用同一个账户并发取钱的问题:
首先定义账户类,封装属性
public class Account{
//封装账户编号、账户余额
private String accountNo;
private double balance;
//构造函数
public Account(String accountNo,double balance){
this.accountNo=accountNo;
this.balance=balance;
}
//省略getter和setter
//。。。
//根据accountNo重写hashCode()和equals()方法
public int hasCode(){
return accountNo.hasCode();
}
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;
}
}
接下来提供一个取钱的线程类,该线程类根据执行账户、取钱数量进行取钱操作,取钱的逻辑是当其余额不足时无法提取现金,当余额足够时系统吐出钞票,余额减少。
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(Exception e){
e.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){
//创建一个账户
Account account = new Account("1234567",1000);
new DrawThread("甲",account,800).start();
new DrawThread("乙",account,800).start();
}
}
多次运行上面程序,很有可能会出现如下结果:
甲取钱成功:800.0
乙取钱成功:800.0
余额为:200
余额为:-600
如果将上面//造成错误处的代码取消注释,也必定会出现上面的错误
账户余额只有1000时取出了1600,而且账户余额出现了负值,这不是银行希望的结果。虽然上面程序是人为地使用Thread.sleep(1)来强制线程调度切换,但这种切换也是完全可能发生的——100000次操作只要有1次出现了错误,那就是编程错误引起的。
同步代码块
之所以出现上面的问题,是因为run方法的方法体不具有同步安全性,程序中有两个并发线程在修改Account对象,且系统恰好在被注释的代码的地方执行线程切换,所以出现了问题。
为了解决这个问题,Java的多线程支持引入了同步监视器来解决这个问题,使用同步监视器的通用方法就是同步代码块。同步代码块的语法格式如下:
synchronized(obj){
...//此处代码就是同步代码块
}
上面语法格式中synchronized后括号里的obj就是同步监视器,上面代码的含义是:线程开始执行同步代码块之前,必须先获得对同步监视器的锁定。
任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完成后,该线程会释放对该同步监视器的锁定。
虽然Java程序允许使用任何对象作为同步监视器,但同步监视器的目的是阻止两个线程对同一个共享资源进行并发访问,因此通常推荐使用可能被并发访问的共享资源充当同步监视器。对于上面的取钱模拟程序,我们应该考虑使用账户(account)作为同步监视器。我们把程序修改成如下形式。
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(Exception e){
e.printStackTrace();
}
//修改余额
account.setBalance(account.getBalance()-drawAmount);
System.out.println("\t余额为:"+account.getBalance());
}
else{
System.out.println(getName()+"取钱失败余额不足");
}
}//同步代码块结束,释放同步锁
}
}
上面程序使用synchronized将run()方法里的方法体修改成同步代码块,该同步代码块的同步监视器是account对象,这样的做法符合“加锁→修改→释放锁”的逻辑,任何线程在修改指定资源之前,首先对该资源加锁,在加锁期间其他线程无法修改该资源,当该线程修改完成后,该线程释放对该资源的锁定。通过这种方式就可以保证并发线程在任一时刻只有一个线程可以进入修改共享资源的代码区(也被称为临界区),所以同一时刻最多只有一个线程处于临界区内,从而保证了线程的安全性。
同步方法
与同步代码块对应,Java的多线程安全支持还提供了同步方法,同步方法就是使用synchronized关键字来修饰某个方法,则该方法称为同步方法。对于同步方法而言,无须显式指定同步监视器,同步方法的同步监视器是this,也就是该对象本身。
使用同步方法的可以很方便实现线程安全的类,线程安全类有如下特征:
- 该类的对象可以被多个线程安全的访问
- 每个线程调用该对象的任意方法之后都能得到正确结果
- 每个线程调用该对象的任意方法之后,该对象依旧保持合理状态。
不可变类总是线程安全的,因为它的对象状态不可改变;但可变对象需要额外的方法来保证其线程安全。例如上面的Account就是一个可变类,它的account和balance两个Field都可变,当两个线程同时修改Account对象的balance Field时,程序就出现了异常。下面我们将Account类对balance的访问设置成线程安全的,那么只要把balance的方法修改成同步方法即可。
public class Account{
//封装账户编号、账户余额
private String accountNo;
private double balance;
//构造函数
public Account(String accountNo,double balance){
this.accountNo=accountNo;
this.balance=balance;
}
//省略accountNo的getter和setter
//。。。
//省略hashCode()和equals()方法
//。。。
//因为账户不允许随便修改余额,所以balance提供getter方法
public double getBalance(){return this.balance}
//提供一个线程安全的方法draw()来完成取钱操作
public synchronized void draw(double drawAmount){
//账户余额大于取钱数目
if(account.getBalance()>=drawAmount){
//吐出钞票
System.out.println(getName+"取钱成功:"+drawAmount);
//造成错误
try{
Thread.sleep(1);
}catch(Exception e){
e.printStackTrace();
}
//修改余额
account.setBalance(account.getBalance()-drawAmount);
System.out.println("\t余额为:"+account.getBalance());
}
else{
System.out.println(getName()+"取钱失败余额不足");
}
}
}
}
上面程序中增加了一个代表取钱的draw()方法,并使用了synchronized关键字修饰该方法,把该方法变成同步方法。同步方法的同步监视器是this,因此对于同一个Account账户而言,任意时刻只能有一个线程获得对Account对象的锁定,然后进入draw ()方法执行取钱操作——这样也可以保证多个线程并发取钱的线程安全。
因为Account类中已经提供了draw()方法,而且取消了setBalance()方法,DrawThread线程类需要改写,该线程类的run()方法只要调用Account对象的draw()方法即可执行取钱操作。
synchronized关键字可以修饰方法,可以修饰代码块,但不能修饰构造器、属性等。
在Account里定义draw()方法,而不是直接在run()方法中实现取钱逻辑,这种做法更符合面向对象规则。在面向对象里有一种流行的设计方式:Domain DrivenDesign(领域驱动设计,DDD),这种方式认为每个类都应该是完备的领域对象,例如Account代表用户账户,应该提供用户账户的相关方法;通过draw()方法来执行取钱操作(实际上还应该提供transfer()等方法来完成转账等操作),而不是直接将setBalance()方法暴露出来任人操作,这样才可以更好地保证Account对象的完整性和一致性。
上面的DrawThread类无须自己实现取钱操作,而是直接调用account的draw()方法来执行取钱操作。由于已经使用synchronized关键字修饰了draw()方法,同步方法的同步监视器是this,而this总代表调用该方法的对象——在上面示例中,调用draw()方法的对象是account,因此多个线程并发修改同一份account之前,必须先对account对象加锁。这也符合了“加锁 → 修改→ 释放锁”的逻辑。
可变类的线程安全是以降低程序的运行效率作为代价的,为了减少线程安全所带来的负面影响,程序可以采用如下策略:
- 不要对线程安全类的所有方法都进行同步,只对那些共享资源的方法进行同步。例如上面Account类中的accountNo属性就无须同步,所以程序只对draw()方法进行了同步控制。
- 如果可变类有两种运行环境:单线程环境和多线程环境,则应该为该可变类提供两种版本,即线程不安全版本和线程安全版本。在单线程环境中使用线程不安全版本以保证性能,在多线程环境中使用线程安全版本。
JDK所提供的StringBuilder、StringBuffer就是为了照顾单线程环境和多线程环境所提供的类,在单线程环境下应该使用StringBuilder来保证较好的性能;当需要保证多线程安全时,就应该使用StringBuffer。
释放同步监视器的锁定
任何线程进入同步代码块、同步方法之前,必须先获得对同步监视器的锁定,那么何时会释放对同步监视器的锁定呢?程序无法显式释放对同步监视器的锁定,线程会在如下几种情况下释放对同步监视器的锁定:
- 当前线程的同步方法、同步代码块执行结束时释放同步监视器
- 当前线程在同步代码块、同步方法中遇到了break、return终止了该代码块或方法时获释放同步监视器
- 当前线程在同步代码块、同步方法中出现了未处理的Error或Exception,导致了该代码块、该方法异常结束时会释放同步监视器。
- 当前线程执行同步代码块或同步方法时,程序执行了同步监视器对象的wait()方法,则当前线程暂停,并释放同步监视器。
在如下所示的情况下,线程不会释放同步监视器:
- 线程执行同步代码块或同步方法时,程序调用Thread.sleep()、Thread.yield()方法来暂停当前线程的执行,当前线程不会释放同步监视器。
- 线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器。当然,我们应该尽量避免使用suspend()和resume()方法来控制线程。
同步锁
从Java 5开始,Java提供了一种功能更强大的线程同步机制——通过显式定义同步锁对象来实现同步,在这种机制下,同步锁使用Lock对象充当。
Lock提供了比synchronized方法和synchronized代码块更广泛的锁定操作,Lock实现允许更灵活的结构,可以具有差别很大的属性,并且支持多个相关的Condition对象。
Lock是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。
某些锁可能允许对共享资源并发访问,如ReadWriteLock(读写锁)。Lock、ReadWriteLock是Java5新提供的两个根接口,并为Lock提供了ReentrantLock(可重入锁)实现类;为ReadWriteLock提供了ReentrantReadWriteLock实现类。在实现线程安全的控制中,比较常用的是ReentrantLock(可重入锁)。
读写锁就是再多线程的条件下,读读不加锁,读写和写读和写写都需要加锁。
可重入锁就是外层函数获取锁之后,内层代码仍然可以获取该锁的代码,在同一线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。线程可以进入任何一个它所拥有锁的同步代码块。比如线程A获取锁,进入到 A() 方法,然后调用B方法,B方法也加了锁,但是可以直接获取到锁。不需要重新申请。
可重入锁ReentrantLock的代码格式如下:
class X{
//定义锁对象
private final ReentrantLock lock = new ReentrantLock();
//...
//定义需要保证线程安全的方法
public void func(){
//加锁
lock.lock();
try{
//需要保证线程安全的代码
//。。。
}
//使用finally保证释放锁
finally{
lock.unlock();
}
}
}
使用ReentrantLock对象来进行同步,加锁和释放锁出现在不同的作用范围内时,通常建议使用finally块来确保在必要时释放锁。通过使用ReentrantLock对象,我们可以把Account类改为如下形式,它依然是线程安全的。
public class Account{
//定义锁对象
private final ReentrantLock lock = new ReentrantLock();
//其余地方与同步方法中一样,以下为不一样的地方对draw方法的修改
public void draw(){
//加锁
lock.lock();
try{
//账户余额大于取钱数目
if(account.getBalance()>=drawAmount){
//吐出钞票
System.out.println(getName+"取钱成功:"+drawAmount);
//造成错误
try{
Thread.sleep(1);
}catch(Exception e){
e.printStackTrace();
}
//修改余额
account.setBalance(account.getBalance()-drawAmount);
System.out.println("\t余额为:"+account.getBalance());
}
else{
System.out.println(getName()+"取钱失败余额不足");
}
}
finally{
//释放锁
lock.unlock();
}
}
}
使用Lock与使用同步方法有点相似,只是使用Lock时显式使用Lock对象作为同步锁,而使用同步方法时系统隐式使用当前对象作为同步监视器,同样都符合“加锁→修改→释放锁”的操作模式,而且使用Lock对象时每个Lock对象对应一个Account对象,一样可以保证对于同一个Account对象,同一时刻只能有一个线程能进入临界区。
同步方法/代码块与锁
虽然同步方法和同步代码块的范围机制使得多线程安全编程非常方便,而且还可以避免很多涉及锁的常见编程错误,但有时也需要以更为灵活的方式使用锁。Lock提供了同步方法和同步代码块所没有的其他功能,包括用于非块结构的tryLock()方法,以及试图获取可中断锁的lockInterruptibly()方法,还有获取超时失效锁的tryLock(long,TimeUnit)方法。
六、线程的通信
传统的线程通信
使用Condition控制线程通信
使用阻塞队列(BlockingQueue)控制线程通信
七、线程池
Java5的线程池
Java7新增的线程池
八、线程相关类
ThreadLocal类
包装线程不安全的集合
线程安全的集合
未完待续,持续更新。。。