浅谈Java多线程机制
(-----文中重点信息将用红色字体凸显-----)
一、话题导入
在开始简述Java多线程机制之前,我不得不吐槽一下我国糟糕的IT界技术分享氛围和不给力的互联网技术解答深度。当一个初学java的小哥向我请教Java多线程机制相关问题时,我让他去寻求度娘的帮助,让他先学会自己尝试解决问题;但是他告诉我在网上找不到他想要的信息,我也尝试性的在网上收刮了半天,也确实找不到内容详尽、表述清晰的文献。更遗憾的是某些也许有一定参考价值的文档都需要通过非正常手段下载,比如注册、回复甚至是花钱购买,这难免会让不少人望而却步,最后不了了之。
我并不是蓄意抨击,而是希望更多的人能够向LINUX自由之父Stallman一样,学会奉献;如果大家都能够尝试去奉献,最终每个人也将更易于索取。
(以后得空将会陆续将Java各知识点归类总结,并放在CSDN个人博客中;出Java之外还考虑介绍下其他方面的内容,届时请保持关注哟^( 。。)^)
二、现实中的类似问题
假设你是某快餐店的老板,随着自己的苦心经营,终于让快餐店门庭若市、生意兴隆;为了拓展销路,你决定增加送餐上门服务,公司财务告诉你你可以为拓展此业务支配12万元,这个时候你会怎么支配这笔钱呢?
当然有很多种支配方式,并且在支配上需要考虑到人员数量、送餐范围、送餐形式等多个问题;这里我们集中讨论下送餐形式这个细节:
1)买一辆雪弗兰赛欧;
2)买15辆电瓶车;
除去员工工资等基本成本过后剩余的钱用于购买送餐工具,上面我给出了两种送餐交通工具,他们都有各自的优点:首先,雪弗兰赛欧能够达到更快的送餐速度,而且可以供应的送餐范围更广;其次,用电瓶车作为送餐交通工具可以同时为多个顾客派送,并且运送成本显然更加低廉。在这两者之间,你会作何选择呢?
显然是第二种送餐交通工具更加实用:相较之下,后者可以处理的顾客数量更多,靠后的顾客等待时间明显缩短。试想一下,如果你打了电话定了午饭,就因为你是第25个顾客,晚上六点才给你送来,你会是什么心情?
其实,快餐店老板选择多辆电瓶车进行送餐的考虑同进程选择多线程控制的思想是如出一辙的,单线程的程序往往功能非常有限,在某些特定领域甚至不能达到我们所期望的效能。例如,当你想让服务器数据能够被多个客户同时访问时,单线程将让这一设想化为泡影;单线程情况下,多个客户的需求将存入一个栈队,并且依次执行,靠后的客户很难有较好的访问体验。
Java语言提供了非常优秀的多线程支持,多线程的程序可以包含多个顺序执行流,且多个顺序执行流之间互不干扰。总的来说,使用多线程编程有如下多个优点:
1)多个线程之间可以共享内存数据;
2)多个线程是并发执行的,可以同时完成多个任务;
3)Java语言内置了多线程功能支持,从而简化了Java的多线程编程。
三、线程的创建和启动
Java使用Thread类代表线程,所有线程对象都是Thread类或者其子类的实例。创建线程的方式有三种,分别是:
1)继承Thread类创建线程;
2)实现Runnable接口创建线程;
3)使用Callable和Future创建线程。
以上三种方式均可以创建线程,不过它们各有优劣,我将在分别叙述完每一种创建线程的方式后总结概括。
3.1 继承Thread类创建线程
主要步骤为:
① 定义一个类并继承Thread类,此类中需要重写Thread类中的run()方法,这个run()方法就是多线程都需要执行的方法;整个run()方法也叫做线程执行体;
② 创建此类的实例(对象),这时就创建了线程对象;
③ 调用线程对象的start()方法来启动该线程。
举例说明:
<span style="font-size:12px;">
public class MyThread extends Thread
{
public static void main(String[] args)
{
MyThread m1 = new MyThread();
MyThread m2 = new MyThread();
m1.start();//调用start()方法来开启线程
m2.start();</span>
}
private int a;
public void run()//重写run()方法
{
for ( ; a<100 ; a++ )
{
System.out.println(getName()+"-----"+a);
//通过继承Thread类来创建线程时,可以通过getName()方法来获取线程的名称
}
}
}
</span>
上面通过一个简单的例子演示了创建线程的第一种方法(通过继承Thread类创建线程);通过运行以上代码发现有两个线程在并发执行,它们各自分别打印出0-99。由于没有对线程进行显示的命名,所以系统默认这两个线程的名称为Thread-0和Thread-1,num会跟随线程的个数依次递增。具体怎样定义线程名称,我将在后面提及。
那么在上述例子中一共有多少个线程在运行呢?答案是三个!
分别是main(主线程)、Thread-0和Thread-1;我们在多线程编程时一定不要忘记Java程序运行时默认的主线程,main()方法的方法体就是主线程的线程执行体;同理,run()方法就是新建线程的线程执行体。
PS: 其实上述例子中创建线程的代码(标红)可以简化,使用匿名对象来创建线程:
<span style="font-family:Microsoft YaHei;font-size:12px;"><span style="font-size:12px;">new MyThread().start();
new MyThread().start();
</span></span>
------------------------------------------------------------------------------------------------------------------------------------------
在程序中如果想要获取当前线程对象可以使用方法:Thread.currentThread();
如果想要返回线程的名称,则可以使用方法:getName();
故如果想要获取当前线程的名称可以使用以上二者的搭配形式:Thread.currentThread().getName();
此外,还可以通过setName(String name)方法为线程设置名字;具体操作步骤是在定义线程后用线程对象调用setName()方法:
<span style="font-family:Microsoft YaHei;font-size:12px;">MyThread m1 = new MyThread();
m1.setName("xiancheng1");</span>
如此便能将线程对象m1的名称由Thread-0改变成xiancheng1。
------------------------------------------------------------------------------------------------------------------------------------------
在讨论完设置线程名称及获取线程名称的话题后,我们来分析下变量的共享。从以上代码运行结果来看,线程Thread0和线程Thread1分别输出0-99,由此可以看出,使用继承Thread类的方法来创建线程类时,多个线程之间无法共享线程类的实例变量。
3.2 实现Runnable接口创建线程类
主要步骤为:
① 定义一个类并实现Runnable接口,重写该接口的run()方法,run()方法的方法体依旧是该线程的线程执行体;
② 创建定义的类的实例,并以此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象;
③ 调用线程的start()方法来启动该线程。
举例说明:
public class MyThread implements Runnable
{
public static void main(String[] args)
{
MyThread m1 = new MyThread();
Thread t1 = new Thread(m1,"线程1");
Thread t2 = new Thread(m1,"线程2");
t1.start();
t2.start();
}
private int i;
public void run()
{
for ( ; i<100 ; i++ )
{
System.out.println(Thread.currentThread().getName()+" "+i);
}
}
}
运行上面的程序可以看出:两个子线程的i变量是连续的,也就是说采用Runnable接口的方式创建的两个线程可以共享线程类的实例属性,这是因为我们创建的两个线程共用同一个target(m1),所以多个线程可以共享同一个线程类的实例属性。通过对以上两种创建新线程的方法进行比较分析,可以知道两种创建并启动多线程方式的区别是:通过继承Thread类创建的对象即是线程对象,而通过实现Runnable接口创建的类对象只能作为线程对象的target。
3.3 通过Callable和Future创建线程
Callable接口是在Java5才提出的,它是Runnable接口的增强版;它提供了一个call()方法作为线程执行体,且call()方法比run()方法更为强大,主要体现在:
① call()方法可以有返回值;
② call()方法可以申明抛出异常。
Java5提供了Future接口来代表Callable接口里call()方法的返回值,并为Futrue接口提供一个FutureTask实现类,此实现类实现了Future接口,并且实现了Runnable接口,可以作为Thread类的target。不过需要提出的是,Callable接口有泛型限制,Callable接口里的泛型形参类型于call()方法返回值类型相同。
主要步骤为:(创建并启动有返回值的线程)
① 创建Callable接口的实现类,并实现call()方法作为线程的执行体,且该call()方法有返回值;
//不再是void
② 创建Callable接口实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值;
③ 使用FutureTask对象作为Thread对象的target创建并启动新线程;
④ 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。
举例说明:
public class MyThread implements Callable<Integer>//泛型
{
public static void main(String[] args)
{
MyThread m1 = new MyThread();//创建Callable对象
//使用FutureTask来包装Callable对象
FutureTask<Integer> task = new FutureTask<Integer>(m1);
Thread t1 = new Thread(task,"有返回值的线程");
t1.start();//启动线程
//获取线程返回值
try
{
System.out.println("子线程的返回值:"+task.get());
}
catch (Exception ex)
{
ex.printStackTrace();
}
}
public Integer call()//返回值类型为Integer
//泛型在集合框架部分会详细介绍
{
int i = 0;
for ( ; i<100 ; i++ )
{
System.out.println(Thread.currentThread().getName()+" "+i);
}
return i;//call()可以有返回值
}
}
其实,创建Callable实现类与创建Runnable实现类没有太大区别,只是Callable的call()方法允许声明抛出异常,而且允许带返回值。
3.4 三种创建线程方法的对比
由于实现Runnable接口和实现Callable接口创建新线程方法基本一致,这里我们姑且把他们看作是同一类型;这种方式同继承Thread方式相比较,优劣分别为:
1.采用实现Runnable接口和Callable接口的方式创建多线程
① 优点:
1)实现类只是实现了接口,所以它还可以继承其他类;
2)多个线程可以共享一个target,所以适合多线程处理同一资源的模式,从而可以将CPU、代码和数据分开,较好的体现了面向对象的思想。
② 缺点:
1)编程比较复杂,如果需要访问当前线程,则必须使用Thread.currentThread()方法。
2.采用继承Thread类的方式来创建新线程
① 优点:
1)编写简单,如果需要访问当前线程,则只需要使用this即可。
② 缺点:
1)因为线程已经继承了Thread类,所以不能再继承其他类。
3.总结
① 综合分析,我们一般采用实现Runnable接口和实现Callable接口的方式来创建多线程。
四、线程的生命周期
4.1 CPU运行机制简介
一般情况下,计算机在一段时间内同时处理着多个进程,然而多个线程的执行顺序并不是依次进行的,而是“同时”在进行;其实这是我们产生的“错觉”。CPU有自己的工作频率,我们称之为主频;它的意思是CPU单位时间(一般定义为1s)内处理单元运算的次数。一般来说,频率越高,CPU的性能就更加优越。正是因为CPU有着很高的工作频率,才能在不同进程之间进行快速的切换,才会给我们造成一种多个任务在同时进行的假象。可以这么说,计算机在某一时刻只能处理单个进程的某一段运算单元(多核处理器的计算机除外)。
4.2 线程的状态
当新线程被创建后,他并不是一建立就进入运行状态,也不是一直在运行;由于CPU工作时是在多个进程间不停的切换运行,所以线程会处于多种运行状态,它们包括:新建、就绪、运行、阻塞和死亡(不同的人可能对线程状态的分类持不同意见,这里我们就不深究了)。
1. 新建和就绪状态
当程序使用了new关键字创建了一个线程之后,该线程就处于新建状态。当线程对象调用了start()方法之后,该线程便处于就绪状态,处于这个状态的线程并没有开始运行,而只是表示它可以运行了;不过该线程具体什么时候开始运行,完全取决于JVM里线程调度器的调度,这是具有随机性的,这是一种抢占式的调度策略。
需要注意的是:我们启动一个线程使用的是start()方法,而不是调用线程对象的run()方法。调用start()方法来启动线程,系统会把该run()方法当作线程执行体来处理,但如果直接调用线程对象的run()方法,则run()方法会直接被执行,并且在run()方法返回之前其他线程无法并发执行;此时系统会把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体。
2. 运行和阻塞状态
如果处于就绪状态的线程获得了CPU执行权,开始执行run()方法的线程执行体,则该线程便处于运行状态。
阻塞状态只能由运行状态进入,而处于阻塞状态的线程只有重新回到就绪状态才能开始下一次运行;换句话说:进入阻塞状态的线程不能直接再运行。当然,运行状态的线程并不是只能通过“运行—》阻塞—》就绪—》运行”方法才能重新运行,它可以直接从运行状态恢复到就绪状态,这里要用到yield()方法。
线程进入阻塞状态的情况有:
① 线程调用sleep()方法,主动放弃了可执行资格;
② 当前线程想获取的锁被另一个线程所持有;
③ 线程在等待被唤醒;
④ 线程调用了suspend()方法将该线程挂起。(此方法容易产生死锁,不推荐使用)
线程从阻塞状态进入就绪状态的情况有:
① 线程sleep()时间已到;
② 线程成功获取了锁;
③ 线程被唤醒;
④ 处于挂起状态的线程被调用了resume()恢复方法。
可以看出线程进入阻塞状态和线程进入就绪状态的方法或途径是一一对应的。
3. 线程死亡状态
线程死亡的情况有:
① run()或call()方法执行完成,线程正常结束;
② 线程抛出异常或者错误;
③ 调用线程的stop()方法结束线程。(此方法容易导致死锁,故不推荐使用)
主线程和其他线程之间的关系:
一旦我们建立新线程,它将独立于主线程运行,不受主线程的约束和影响,他们拥有相同的地位;当主线程结束时,其他线程不会受其影响而结束。后面会介绍另外一种线程—后台线程,只要前台线程全部结束,后台线程也会自动结束;后台线程充当的是辅助前台线程的角色,所以后台线程也叫“守护线程”。
为了测试某线程是否已经死亡,可以调用其isAlive()方法,当线程处于就绪、运行和阻塞三种状态时,方法返回值为true,当线程处于其他两种状态时,此方法返回值为false。
需要注意的是:
① 不要试图对一个已经死亡的线程调用start()方法,否则会抛出“IllegalThreadStateException”异常;
② 不要试图对一个线程进行多次start()方法调用,否则也会抛出“IllegalThreadStateException”异常。
4. 线程状态转换关系
关于线程多个状态之间的转换关系,可以用以下转换图来表示:
五、控制线程
5.1 join线程
Thread提供线程“插队”的方法,就是让一个线程等待另一个线程完成的方法-----join()方法,目的是让当下线程等待插入线程运行完成后再继续运行。它一般用于将大问题划分成许多小问题,每个小问题用一个小线程完成;这有点像“坐公交车”,公共汽车就是主线线程,乘客就是插入辅助小线程,小线程在“某站”上车,待到目的地就下车,许多小线程为完成自己的目的在特定时间插入又在特定时间结束。
举例说明:
public class JoinThreadTest extends Thread//也可通过实现接口来定义
{
public JoinThreadTest(String name)
{
super(name);
}
public void run()
{
int i=0;
for ( ; i<200 ; i++ )
{
System.out.println(getName()+"--第--"+i+"--次");
//由于这里是继承Thread类,所以可以直接使用getName()方法来获取线程名称
}
}
public static void main(String[] args) throws Exception
{
for ( int a = 0 ; a<200 ; a++ )
{
if (a==20)
{
JoinThreadTest jt1 = new JoinThreadTest("插入线程");
jt1.start();//启动子线程
jt1.join();
}
System.out.println(Thread.currentThread().getName()+"--第--"+a+"--次");
}
}
}
上例中共有两个线程存在,分别是主线程和新建线程jt1,由于虚拟机首先从main()主函数开始读取,所以主函数开始执行,等到变量a等于20时,开始执行if内部代码块,此段代码新建一个线程jt1并启动该线程。由于jt1线程使用了join()方法,则主线程会等待jt1线程执行完成后才能继续执行。需要指出的是,通过继承Thread()类建立新线程,获取线程名称可以直接使用getName()方法,但是由于此方法是非静态方法,所以在主函数执行体中获取主线程名称不能直接使用getName()方法,必须使用完整的获取线程名称的方法-----Thread.currentThread().getName(),否则会报错。
join()方法有一定的灵活性。由于它的“强制性”,我们在调用此方法后插入线程需要执行完成后原线程才能继续执行,但是有的时候我们并不需要这样的效果,我们可能希望设定插入线程执行一定的时间然后返回原线程继续执行。join()方法的重载形式便应运而生了:
① join():等待被插入的线程执行完成;
② join(long millis):等待被插入的线程的时间最长为millis毫秒。
对于第二种方法,会出现两种情况:如果在特定时间内插入线程提前完成,则原线程还是需要等待直到特定时间后才能继续执行;第二种情况是如果插入线程在特定的时间内没有完成执行任务,则原线程不再等待并开始继续执行,如此原线程和插入线程又处于并列运行状态。
还有另外一种重载形式,但是对时间精度要求过高,几乎没有“用武之地”,这里就不细说了。
5.2 后台线程
顾名思义,后台线程就是在后台运行的线程,它是为其他线程提供服务的,所以后台线程也叫做“守护线程”。Java的垃圾回收线程就是典型的后台线程。
后台线程较为特殊,如果所有的前台线程都死亡,则后台线程也会随之自动死亡;就如无本之木,没有了实际的意义和存在的必要。
调用Thread对象的setDaemon(true)方法可以将指定线程设置成后台线程。
举例说明:
public class DaemonThreadTest extends Thread
{
static int i=0;
public void run()
{
for ( ; i<100 ; i++ )
{
System.out.println(getName()+"----"+i);
}
}
public static void main(String[] args)
{
DaemonThreadTest dtt = new DaemonThreadTest();
//设置此线程为后台线程
dtt.setDaemon(true);
dtt.start();
int a=0;
while (a<10)
{
System.out.println(Thread.currentThread().getName()+"~~~~"+a);
a++;
}
if (i<99)
{
System.out.println("后台线程dtt没有执行完成就退出了!"+i);
}
}
}
笔者在运行以上代码的时候会出现想要的结果:主线程main执行完成后,新建线程Thread-0还没有执行完,最后if语句中的文字”后台线程dtt没有执行完就退出了!“输出;可以证明辅助线程在主线程执行完成后就随之死亡,哪怕自己还没有执行完成。但是笔者在运行上述代码的时候发生了一件看似诧异的事情:if语句输出的i的值比run()方法里的i值要小一点,这其实是容易理解的----前台线程死亡后,JVM会通知后台线程死亡,但是从后台线程接收指令并做出反应需要一定的时间,所以导致run()方法在这个时间差里继续运行,才导致了两个i值不同。
在这里笔者要重申一点:由于上述代码中选用的i变量范围较小,故有的时候可能看到的情况是dtt线程执行完成了,这并不是代码错误了,而是由于执行内容少代码瞬间就执行完成了,这是由于处理器性能和随机性决定的。如果我们把变量i的范围调整到1000,出现想要结果的可能性就会大很多。
Thread类提供了一个判断线程是否为后台线程的方法----isDaemon()方法。
需要指出的是,前台线程如果创建子线程依旧默认是前台线程;同理,后台线程创建的子线程默认是后台线程。此外,setDaemon(true)必须在start()方法之前调用,否则会引发IllegalThreadStateException异常。
5.3 线程等待---sleep()
如果需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用Thread类的静态方法sleep()来实现,sleep()方法主要形式为:
<span style="font-family:Microsoft YaHei;font-size:12px;"> static void sleep(long millis);</span>
括号里的参数表示的是线程等待的时间。当线程进入等待状态,在暂停时间范围内线程无法”提前“开始执行,哪怕系统中没有其他线程运行。下面的例子将说明这一点:
public class SleepTest
{
public static void main(String[] args) throws Exception
{
for (int a=0 ;a<200 ; a++ )
{
System.out.println("a的值是: "+a);
if (a==50)
{
Thread.sleep(2000);
//括号中参数的单位是毫秒
}
}
}
}
运行上面代码我们可以发现,程序中只有一个线程----主线程,当for循环执行到a等于50的时候会停顿两秒然后再接着执行直到进程结束。
5.4 线程让步----yield()
其实yield()方法同sleep()方法比较类似,它们的共同点就是放弃当前执行权。但是它们也有明显的区别:sleep()方法是让线程放弃当前执行权并转入阻塞状态,而yield()方法是让当前线程放弃执行权后进入就绪状态;此外,前者是规定了线程等待的具体时间,而后者只是让当前线程暂停一下,让线程调度器重新调度,完全可能发生的情况是:当某个线程调用了yield()方法暂停后,线程调度器又将其调度出来重新执行,而期间没有其他线程插入。
需要指出的是,某个线程执行了yield()方法后,只有优先级大于或等于当前线程优先级的线程才会获得执行机会,等待会介绍了设置线程优先级后笔者会用例子加以说明。
总结两方法的异同,sleep()方法和yield()方法区别如下:
① sleep()方法暂停当前线程后将执行权让出给其他线程,而yield()方法只会把执行权让给优先级大于或等于自己优先级的线程;
② sleep()方法将当前线程转入阻塞状态,而yield()方法则把当前线程转入就绪状态;
③ sleep()方法声明抛出了InterruptedException异常,所以调用sleep()方法时需要对异常进行相应,要么处理要么抛出,而yield()方法没有声明抛出任何异常;
④ sleep()方法比yield()方法有更好的移植性,通常不建议使用yield()方法来控制并发线程的执行。
5.5 改变线程的优先级
简单的说,线程有一定的优先级,优先级从1到10不等;而主线程和新建线程默认优先级为普通,用数字表示就是优先级为5。优先级越高,或的执行权的可能也就越大。Thread类提供了setPriority(int newPriority)、getPriority()方法来设置和返回指定线程的优先级;一般情况下,线程优先级如果用数字表示相差不大的情况下效果不是很明显,而且由于用数字表示优先级移植性不佳,故我们一般只取三种优先级,并赋予特殊的名称:
① MAX_PRIORITY:其值是10;
② MIN_PRIORTY:其值是1;
③ NORM_PRIORITY:其值是5。
结合yield()方法和线程优先级知识,笔者举例加以巩固:
public class YieldTest extends Thread
{
public YieldTest(String name)
{
super(name);
}
static int a=0;
public void run()
{
for ( ; a<100 ; a++ )
{
System.out.println(getName()+"----"+a);
if (a==50)
{
Thread.yield();
}
}
}
public static void main(String[] args)
{
YieldTest yt1 = new YieldTest("高级线程");
yt1.setPriority(Thread.MAX_PRIORITY);
yt1.start();
YieldTest yt2 = new YieldTest("低级线程");
yt2.setPriority(Thread.MIN_PRIORITY);
yt2.start();
}
}
上述代码共创建了两个新线程,两个线程共用一个变量a;当运行以上代码时可以清楚的看见,在for循环100次的执行过程中,线程yt1(也就是高级线程)获得执行的次数要多余yt2(也就是低级线程)所执行的次数。此外,由于yield()方法的特殊性,我们几乎感觉不到调用了yield()方法带来的线程切换。
六、线程同步
6.1 线程安全问题分析
使用多线程可以提高进程的执行效率,但是它也伴随着一定的风险;这是由系统的线程调度具有一定的随机性造成的,我们首先通过一个大家耳熟能详的例子来说明多线程引发的同步问题----银行取钱。
我们按照生活中正常的取、存钱操作编写如下代码:
public class DrawTest
{
public static void main(String[] args)
{
//创建账户
Account acct=new Account("公共账户",1000);
//模拟两个线程对同一个账户取钱
new DrawThread("客户甲",acct,800).start();//匿名对象
new DrawThread("客户乙",acct,800).start();
}
}
class Account
{
//建立并封装用户编号和账户余额两个变量
private String number;
private double balance;
//建立构造器进行初始化
public Account(String number,double balance)
{
this.number = number;
this.balance = balance;
}
public void setNumber(String number)
{
this.number=number;
}
/*
public void setBalance(double balance)
{
this.balance=balance;
}
*/
public String getNumber()
{
return number;
}
public double getBalance()
{
return balance;
}
//为了判断用户是否是同一个用户,我们重写hashCode()和equals()方法来进行判断
public int hashCode()
{
return number.hashCode();
}
public boolean equals(Object obj)
{
if (this==obj)
{
return true;
}
if (obj!=null&&obj.getClass()==Account.class)
{
Account target = (Account)obj;
return target.getNumber().equals(number);
}
else
return false;
}
}
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(10);
}
catch (InterruptedException ex)
{
ex.printStackTrace();//打印异常信息
}
//修改余额
account.setBalance(account.getBalance()-drawAmount);
System.out.println("余额为:"+account.getBalance());
}
else
{
System.out.println(getName()+"取钱失败,余额不足!");
}
}
}
运行上面代码会发现不符合实际的情况发生:账户余额只有1000却取出了1600,而且账户余额出现了负值,这不是银行希望的结果。这种滑稽的错误是因为线程调度的不确定性,run()方法的方法体不具有同步安全性;程序中有两个并发线程在修改Account对象。
6.2 同步代码块
由银行取钱“风波”可以了解到,当有两个进程并发修改同一个文件时就有可能造成异常。为了解决这个问题,Java的多线程支持引入了同步监视器来解决这个问题,使用同步监视器的通用方法就是同步代码块,同步代码块的语法格式如下:
<span style="font-family:Microsoft YaHei;font-size:12px;"> synchronized (对象)
{
需要被同步的代码;
}
</span>
上面代码中,synchronized后括号中的对象就是同步监视器,线程在执行同步代码块之前需要先获得同步监视器的锁。同步代码块的同步监视器为对象,我们一般选用Object类来创建对象,这个对象就是锁。
<span style="font-family:Microsoft YaHei;font-size:12px;"> Object obj = new Object();</span>
任何时刻只能有一个线程获得对同步监视器的锁定,当同步代码块执行完成后,该线程会释放锁。如此,我们将银行取钱问题的代码稍加修改,就能达到我们想要的运算结果:
public void run()
{
synchronized(account)
{
if (account.getBalance()>=drawAmount)//判断余额是否大于取钱数
{
System.out.println(getName()+"取钱成功!"+drawAmount);
try
{
Thread.sleep(10);
}
catch (InterruptedException ex)
{
ex.printStackTrace();//打印异常信息
}
//修改余额
account.setBalance(account.getBalance()-drawAmount);
System.out.println("余额为:"+account.getBalance());
}
else
{
System.out.println(getName()+"取钱失败,余额不足!");
}
}
}
上面程序使用synchronized将run()方法里的方法体修改为同步代码块,该同步代码块的同步监视器就是account对象,这样的做法符合“加锁-修改-解锁”的逻辑;通过这种方式可以保证并发线程在任意时刻只有一个线程可以进入修改共享资源的代码区,从而保证了线程的安全性。
6.3 同步函数
同步函数就是使用synchronized关键字修饰的方法,同步函数的同步监视器是this,也就是调用方法的对象本身。需要指出的是,synchronized关键字可以修饰方法和代码块,但是不能修饰构造器、属性等。
同步的前提:
① 必须要有两个或两个以上的线程;
② 必须是多个线程使用同一个锁;
③ 必须保证同步中只有一个线程在运行;
为了减少保证线程安全而带来的负面影响(例如更加消耗资源),程序可以进行优化和控制:
① 只对那些会改变竞争资源的方法或代码进行同步;
② 如果可变类有两种运行环境:单线程和多线程环境,则应该为该可变类提供两种版本,即线程安全版本和线程不安全版本。
如果同步函数为静态同步,则其同步监视器就是:类名.class。
6.4 同步在单例设计模式中的应用
单例设计模式,顾名思义就是一个类只能创建一个对象;单例设计模式一共分为两种,分别是饿汉式和懒汉式。由于饿汉式在一开始就建立了对象并初始化提供了调用的方法,故饿汉式在多线程情况下没有安全隐患,不会引起多线程异常;而懒汉式由于需要对对象是否为空进行判断,所以可能导致多线程异常。
饿汉式单例设计模式:
class single
{
private static single s = new single;
private single(){}
public static single getInstance()
{
return s;
}
}
懒汉式单例设计模式:
class single
{
private static single s = null;
private single(){}
public static single getInstance{}
{
if (s==null)
{
synchronized(single.class)
{
if (s==null)//二次判断
{
s=new single();
}
}
}
return s;
}
}
6.5 释放同步监视器的锁定
线程会在如下几种情况释放对同步监视器的锁定:
① 当前线程的同步方法或者同步代码块执行完毕;
② 当前线程的同步方法或者同步代码块中遇到break、return终止了该代码块或该方法的继续执行;
③ 当前线程的同步方法或者同步代码块中遇到未处理的error或Exception,导致了该代码块或该方法异常而结束;
④ 程序执行了同步监视器对象的wait()方法。
线程在如下情况下不会释放同步监视器:
① 程序调用Thread.sleep()、Thread.yield()方法来暂停当前线程的执行;
② 其他线程调用了当前线程的suspend()方法将当前线程挂起。
6.6 同步锁
从Java5开始,Java提供了一种功能更加强大的线程同步机制-----通过显示定义同步锁对象来实现同步,在这种机制下,同步锁采用Lock对象充当。
Lock提供了比synchronized方法和synchronized代码块更广泛的锁定操作,Lock实现了允许更灵活的结构,Lock是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。在实现线程安全的控制中,比较常用的是ReentrantLock(可重入锁),使用该Lock对象可以显示的加锁、释放锁。
举例说明:
class LockTest
{
//用ReentrantLock类定义锁对象
private final ReentrantLock lock= new ReentrantLock();
//将此锁应用在需要保证线程安全的方法上
public void test()
{
//加锁
lock.lock();
try
{
//需要保证线程安全的代码
}
catch (Exception e)
{
System.out.println("发生错误信息,请重新确认代码!");
}
finally
{
//释放锁
lock.unlock();
}
}
}
使用ReentrantLock对象来进行同步,加锁和释放锁出现在不同的作用范围内时,通常建议使用finally块来确保在必要时释放锁。前面介绍的银行存取钱例子中,可以使用ReentrantLock类定义的锁来保证线程安全,而且相较于synchronized代码块或synchronized方法更加简洁方便。
使用Lock与使用同步方法有点类似,只是使用Lock时显示使用Lock对象作为同步锁,而使用同步方法时系统隐式使用当前对象作为同步监视器。此外,Lock提供了同步方法和同步代码块所没有的其他功能:
① 用于非结构块的tryLock()方法;
② 试图获取可中断锁的lockInterruptibly()方法;
③ 获取超时失效锁的tryLock(long,TimeUnit)方法。
ReentrantLock锁具有可重入性,一个线程可以对已被加锁的ReentrantLock锁再次加锁,ReentrantLock对象会维持一个计数器来追踪lock()方法的嵌套使用,线程在每次调用lock()加锁后,必须显示调用unlock()来释放锁,所以一段被锁保护的代码可以调用另一个被相同锁保护的方法。
6.7 死锁
当两个线程相互等待对方释放同步监视器时就会发生死锁,Java虚拟机没有监测,也没有采取措施来处理死锁情况,所以多线程编程时应采取措施避免死锁出现;一旦出现死锁,整个程序既不会发生任何异常,也不会给出提示,只是所有线程处于阻塞状态,无法继续。
举例说明:
class A
{
public synchronized void foo(B b)
{
System.out.println("当前线程名称为:"+Thread.currentThread().getName()+"进入了A实例的foo方法");
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 bar(A a)
{
System.out.println("当前线程名称为:"+Thread.currentThread().getName()+"进入了B实例的bar方法");
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方法内部");
}
}
public class DeadLockTest implements Runnable
{
A a=new A();
B b=new B();
public void init()
{
Thread.currentThread().setName("主线程");
a.foo(b);
System.out.println("进入了主线程之后");
}
public void run()
{
Thread.currentThread().setName("副线程");
b.bar(a);
System.out.println("进入了副线程之后");
}
public static void main(String[] args)
{
DeadLockTest d1=new DeadLockTest();
new Thread(d1).start();
d1.init();
}
}
6.8 线程通信
线程间通信方法:
① wait():导致当前线程等待,括号中可以定义等待时间,若不定义等待时间,则需要等待至被唤醒;
② notify():唤醒在此同步监视器上等待的单个线程,如果多个线程在等待,则随机唤醒其中一个线程;
③ notifyAll():唤醒在此同步监视器上的所有线程。
需要注意的是,以上三个方法并不属于Thread类,而是属于Object类。对于使用synchronized修饰的同步方法,因为该类的默认实例(this)就是同步监视器,所以可以在同步方法中直接调用这三个方法;而同步代码块中同步监视器是synchronized后括号里的对象,所以必须使用该对象调用这三个方法。
如果程序不是通过synchronized关键字来保证同步,而是使用Lock对象来保证同步,则系统中不存在隐式的同步监视器,也就不能使用上述三个方法来进行线程间通信了,Java提供了一个Condition类来保持协调,使用Condition可以让那些已经得到Lock对象却无法继续执行的线程释放Lock对象,Condition对象也可以唤醒其他处于等待的线程。Condition实例被绑定在一个Lock对象上,要活的特定Lock实例的Condition实例,调用Lock对象的newCondition()方法即可。同样的,Condition类提供了如下3个方法:
① await():类似于wait()方法,导致当前线程等待;
② signal():唤醒在此Lock对象上等待的单个线程;
③ signalAll():唤醒在此Lock对象上等待的所有线程。
这里还是以取钱的例子来说明:
public class Account
{
private final Lock lock=new ReentrantLock();
private final Condition cond=lock.newCondition();
private String accountNo;
private double balance;
private boolean flag=false;
public Account(){}
public Account(String accountNo,double balance)
{
this.accountNo=accountNo;
this.balance=balance;
}
public void setAccountNo(String accountNo)
{
this.accountNo=accountNo;
}
public String getAccountNo(String accountNo)
{
return accountNo;
}
public double getBalance(double balance)
{
return balance;
}
public void draw(double drawAmount)
{
lock.lock();
try
{
if (!flag)
{
cond.await();
}
else
{
System.out.println(Thread.currentThread().getName()+"取钱:"+drawAmount);
balance -= drawAmount;
System.out.println("账户余额为:"+balance);
flag=false;
cond.signalAll();
}
}
catch (InterruptedException e)
{
e.printStackTrace();
}
finally
{
lock.unlock();
}
}
public void deposit(double depositAmount)
{
lock.lock();
try
{
if (flag)
{
cond.await();
}
else
{
System.out.println(Thread.currentThread().getName()+"存款:"+depositAmount);
balance+=depositAmount;
System.out.println("账户余额为:"+balance);
flag=true;
cond.signalAll();
}
}
catch (InterruptedException e )
{
e.printStackTrace();
}
finally
{
lock.unlock();
}
}
}
七、其他内容
以上是关于多线程机制的基础内容,除此之外,还有关于"线程组和未处理的异常"、"线程池"等基础概念及内容,在这里笔者就不详细阐述了,读者打好多线程机制的基础后,可以自行学习这些拓展内容。
多线程处理机制,就介绍到这里啦!