1、继承Thread类创建多线程
1.1 在学习多线程之前,先来看看单线程程序。
class Mythread{
public void run(){
for(int i=1;i<100;i++){
System.out.println("MyThread类的run()方法在运行");
i+=1;
}
}
}
public class Example01 {
public static void main(String[] args){
Mythread myThread = new Mythread(); //创建MyThread实例对象
myThread.run(); // 调用MyThread类的run()方法
for(int i=1;i<100;i++){
System.out.println("Main方法在运行");
i+=1;
}
}
}
【运行结果】
通过观察运行结果可以发现,程序先执行了子线程,再调用main线程,所以不是多线程的程序。
1.2 通过对上面单线程程序的修改,变为多线程程序,Java提供了一个线程类Thread,通过继承Thread类,并重写Thread类中的run()方法便可实现多线程。在Thread类中,提供了一个start()方法用于启动新线程。
class Mythread extends Thread{
public void run(){
for(int i=1;i<1000;i++){
System.out.println("MyThread类的run()方法在运行");
i+=1;
}
}
}
public class Example01 {
public static void main(String[] args){
Mythread myThread = new Mythread(); //创建MyThread实例对象
myThread.start(); // 调用MyThread类的run()方法
for(int i=1;i<1000;i++){
System.out.println("Main方法在运行");
i+=1;
}
}
}
【运行结果】
通过运行结果可以发现,两线程在交替运行。
2、实现Runnable借口创建多线程
在上面的实例中,通过使用继承Thread类实现了多线程,但是这种方法有一定的局限性。因为Java只支持单继承,一个类一旦继承了某个父类就无法再继承Thread类,例如学生类继承了Person类,就无法通过继承Thread类创建线程。
下面通过一个案例来演示如何通过实现Runnable接口的方式来创建多线程。
【运行代码】
首先通过定义Mythread类实现了与Runnable接口,并重写了Runnable接口中的run()方法;接着通过Thread类的构造方法将Mythread类的实例对象作为参数传入,并使用start()方法开启Mythread线程。
class Mythread implements Runnable{
public void run(){
while(true){
System.out.println("MyThread类的run()方法在运行");
}
}
}
public class Example01 {
public static void main(String[] args){
Mythread myThread = new Mythread(); //创建MyThread实例对象
Thread thread = new Thread(myThread); //创建线程对象
thread.start();
while(true){
System.out.println("Main方法在运行");
}
}
}
【运行结果】
从运行结果可以看出,main()方法和run()方法中的打印语句都执行了,说明实现了多线程
3、两种实现多线程方式的对比分析
假设售票厅有4个窗口可发售某日某次列车的100张车票,这是,100张车票可以看作是共享资源,4个售票窗口需要创建4个线程。
3.1 通过继承Thread类的方式创建多线程
class TicketWindow extends Thread{
private int tickets = 100;
public void run(){
while(true){
if(tickets>0){
Thread th = Thread.currentThread(); //获取当前线程
String th_name = th.getName(); //获取当前线程的名字
System.out.println(th_name+"正在发售第"+tickets-- +"张票");
}
}
}
}
public class Example01 {
public static void main(String[] args){
new TicketWindow().start(); //创建第一个线程对象TicketWindow并开启
new TicketWindow().start(); //创建第一个线程对象TicketWindow并开启
new TicketWindow().start(); //创建第一个线程对象TicketWindow并开启
new TicketWindow().start(); //创建第一个线程对象TicketWindow并开启
}
}
【运行结果】
通过运行结果可以发现,每张票都被打印了4次,出现这种现象的原因是4个线程没有共享100张票,而是各自出售了100张票。在程序中创建了4个TickWindow对象,就等于创建了4个售票程序,每个程序中都有100张票,每个线程都在独立的处理各自的资源;
3.2 由于现实生活中,铁路系统的票资源是共享的,因此上面的运行结果显然不合理。为了保证资源的共享,在程序中只能创建一个售票对象,然后采用开启多个线程去运行同一个售票对象。简单来说,就是4个线程运行同一个售票程序。
下面通过实现Runnable接口的方式来创建多线程。并使用构造方法Thread(Runable target,Sting name)在创建线程对象时指定线程的名称。
class TicketWindow implements Runnable{
private int tickets = 100;
public void run(){
while(true){
if(tickets>0){
Thread th = Thread.currentThread(); //获取当前线程
String th_name = th.getName(); //获取当前线程的名字
System.out.println(th_name+"正在发售第"+tickets-- +"张票");
}
}
}
}
public class Example01 {
public static void main(String[] args){
TicketWindow tw = new TicketWindow(); //创建TicketWindow实例对象tw
new Thread(tw,"窗口1").start(); //创建线程对象并命名为窗口1,开启线程
new Thread(tw,"窗口2").start(); //创建线程对象并命名为窗口2,开启线程
new Thread(tw,"窗口3").start(); //创建线程对象并命名为窗口3,开启线程
new Thread(tw,"窗口4").start(); //创建线程对象并命名为窗口4,开启线程
}
}
【运行结果】
此时就确保了4个线程访问的是同一个tickets变量,共享了100张车票。
实现Runnable接口相对于继承Thread类来说,具有以下优势。
1. 适合多个相同程序代码的线程去处理同一个资源的情况,把线程同程序代码、数据有效分离,很好的体现了面向对象的设计思想。
2. 可以避免由于java的单继承带来的局限性。在开发中经常碰到这样一种情况,即使用一个已经继承了某一个类的子类创建线程,由于一个类不能同时有两个父类,因此不能使用继承Thread类的方式,只能采用Runable接口的方式。
3. 线程的生命周期及状态转换
线程的整个生命周期可以分为5个阶段,分别是新建状态(New),就绪状态(Runnable),运行状态(Running),阻塞状态(Blocked)和死亡状态(Terminated),线程的不同状态表明了线程当前正在进行的活动,在程序中,通过一些操作可以使线程在不同状态之间的转换,如下图
接下来对物种状态分别进行详细讲解
3.1 新建状态(New)
创建一个线程对象后,该线程对象就处于新建状态,此时它不能运行,和其他Java对象一样,仅仅由Java虚拟机为其分配了内存,没有表现出任何线程的动态特征。
3.2 就绪状态(Runable)
当线程对象调用了start()方法后,该线程就进入就绪状态。处于就绪状态的线程位于线程队列中,此时它只是具备了运行的条件,能否获得CPU的使用权并开始运行,还需要等待系统的调度。
3.3 运行状态(Running)
如果处于就绪状态的线程获得了CPU的使用权,并开始执行run()方法中的线程执行体,则该线程处于运行状态。一个线程启动后,它可能不会一直处于运行状态,当运行状态的线程使用完系统分配的时间后,系统就会剥夺该线程占用的CPU资源,让其他线程获得执行的机会。需要注意的是,只有处于就绪状态的线程才可能转换到运行状态。
3.4 阻塞状态(Blocked)
一个正在执行的线程在某些特殊情况下,如被人为挂起或执行耗时的输入/输出操作时,会让出CPU的使用权并暂时中止自己的执行,进入阻塞状态。线程进入阻塞状态后,就不能进入排队队列。只有当引起阻塞的原因被消除后,线程才可以转入就绪状态。
3.5 死亡状态(Terminated)
当线程调用stop()方法或run()方法正常执行完毕后,或者线程抛出一个未捕获的异常(Exception)、错误(Error),线程就进入死亡状态。一旦进入死亡状态,线程将不再拥有运行的资格,也不能再转换到其他状态。
4. 线程的调度
程序中的多个线程是并发执行的,某个线程若想被执行必须要得到CPU的使用权,Java虚拟机会按照特定的机制为程序中的每个线程分配CPU的使用权,这种机制称为线程的调度。
在计算机中,线程调度有两种模型,分别是分时调度模型和抢占式调度模型。所谓分时调度模型是指让所有的线程轮流获得CPU的使用权,并且平均分配每个线程占用的CPU的时间片。抢占式调度模型是指让可运行池中优先级高的线程优先占用CPU,而对于优先级相同的线程,随机选择一个线程使其占用CPU,当它失去了CPU的使用权后,再随机选择其他线程获取CPU使用权。Java虚拟机默认采用抢占式调度模型,通常情况下程序员不需要去关心它,但在某些特定的需求下需要改变这种模式,由程序自己来控制CPU的调度。
4.1 线程的优先级
在应用程序中,如果要对线程进行调度,最直接的方式就是设置线程的优先级。优先级越高的线程获得CPU执行的机会越大,而优先级越低的线程获得CPU执行的机会越小。线程的优先级用1~10之间的整数来表示,数字越大优先级越高。
除了可以直接使用数字表示线程的优先级,还可以使用Thread类中提供的三个静态常量表示线程的优先级,如下表。
Thread类的静态常量 | 功能描述 |
static int MAX_PRIORITY | 表示线程的最高优先级,值为10 |
static int MIN_PRIORITY | 表示线程的最低优先级,值为1 |
static int NORM_PRIORITY | 表示线程的普通优先级,值为5 |
程序在运行期间,处于就绪状态的每个线程都有自己的优先级,例如,main线程具有普通优先级。然而线程优先级不是固定不变的,可以通过Thread类的setPriority(int newPriority)方法进行设置,setPriority()方法中的参数newPriority接收的是1~10之间的整数或者Thread类的三个静态常量。
下面通过一个案例演示不同优先级的两个线程在程序中的运行情况
//定义类MaxPriority实现Runable接口
class MaxPriority implements Runnable{
public void run(){
for(int i = 0;i<10;i++){
System.out.println(Thread.currentThread().getName()+"正在输出:"+i);
}
}
}
//定义类MinPriority
class MinPriority implements Runnable{
public void run(){
for(int i=0;i<10;i++){
System.out.println(Thread.currentThread().getName()+"正在输出:"+i);
}
}
}
public class example02 {
public static void main(String[] args){
//创建两个线程
Thread minPriority = new Thread(new MinPriority(),"优先级较低的线程");
Thread maxPriority = new Thread(new MaxPriority(),"优先级较高的线程");
minPriority.setPriority(Thread.MIN_PRIORITY); //设置线程的优先级为1
maxPriority.setPriority(Thread.MAX_PRIORITY); //设置线程的优先级为10
//开启两个线程
maxPriority.start();
minPriority.start();
}
}
【运行结果】
从运行结果可以看出,优先级较高的maxPriority线程先运行了,运行完毕后优先级较低的minPriority线程才开始运行。所以优先级越高的线程获取CPU切换时间片的机率越大。
4.2 线程休眠
如果希望人为地控制线程,使正在执行的线程暂停,将CPU让给别的线程,这时可以使用静态方法sleep(long millis),该方法可以让当前正在执行的线程暂停一段时间,进入休眠等待状态。当前线程调用sleep(long millis)方法后,在指定时间(单位毫秒)内该线程是不会执行的,这样其他的线程就可以得到执行的机会了。
sleep(long millis)方法声明会抛出InterruptedException异常,因此在调用该方法时应该捕获异常,或者声明抛出该异常。
class SleepThread implements Runnable{
public void run(){
for(int i=1;i<=10;i++){
if(i==3){
try{
Thread.sleep(2000); //当前线程休眠2000毫秒
}
catch(InterruptedException e){
e.printStackTrace();
}
}
System.out.println("SleepThread线程正在输出: "+i);
try{
Thread.sleep(500);
}
catch(Exception e){
e.printStackTrace();
}
}
}
}
public class ex03 {
public static void main(String[] args)throws Exception{
//创建一个线程
new Thread(new SleepThread()).start();
for (int i=1;i<=10;i++){
if(i==5){
Thread.sleep(2000); //当前线程休眠2000毫秒
}
System.out.println("主线程正在输出: "+i);
Thread.sleep(500);
}
}
}
【运行结果】
在主线程与SleepThread类线程中分别调用了Thread的sleep(500)方法让其线程休眠,目的是让一个线程在打印一次后休眠500毫秒,从而使另一个线程获得执行的机会,这样就可以实现两个线程的交替执行。
从运行结果可以看出,主线程输出2后,SleepThread类线程没有交替输出3,而是主线程接着输出了3和4,这说明了当i等于3时,SleepThread类线程进入了休眠等待状态。对于主线程也一样,当i等于5时,主线程会休眠2000毫秒。
需要注意的是:sleep()是静态方法,只能控制当前正在运行的线程休眠,而不能控制其他线程休眠。当休眠时间结束后,线程就会返回到就绪状态,而不是立即开始运行。
4.3 线程让步
在篮球比赛中,我们经常会看到两队选手互相抢篮球,当某个选手抢到篮球后就可以拍一会,之后他会把篮球让出来,其他选手重新开始抢篮球,这个过程就相当于Java程序中的线程让步。所谓的线程让步是指正在执行的线程,在某些情况下将CPU资源让给其他线程执行。
线程让步可以通过yield()方法来实现,该方法和sleep()方法有点相似,都可以让当前正在运行的线程暂停,区别在于yield()方法不会阻塞该线程,它只是将线程转换成就绪状态,让系统的调度器重新调度一次。当某个线程调用yield()方法之后,只有与当前线程优先级相同或者更高的线程才能获得执行的机会。
下面通过一个案例来演示yield()方法的使用
class YieldThread extends Thread{
//定义一个有参的构造方法
public YieldThread(String name){
super(name); // 调用父类的构造方法
}
public void run(){
for(int i = 0; i<6;i++){
System.out.println(Thread.currentThread().getName()+"---"+i);
if(i==3){
System.out.println("线程让步:");
Thread.yield(); //线程运行到此,作出让步
}
}
}
}
public class ex04 {
public static void main(String[] args){
// 创建两个线程
Thread t1 = new YieldThread("线程A");
Thread t2 = new YieldThread("线程B");
//开两个线程
t1.start();
t2.start();
}
}
【运行结果】
for循环中线程在变量i等于3时,调用Thread的yield()方法,使当前线程暂停,这时另一个线程就会获得执行,从运行结果可以看出,当线程B输出3以后,会做出让步,线程A继续执行,同样,线程A输出3后,也会做出让步,线程B继续执行。
4.4 线程插队
现实生活中经常能碰到“插队”的情况,同样,在Thread类中也提供了一个join()方法来实现这个“功能”。当在某个线程中调用其他线程的join()方法时,调用的线程将被阻塞,直到被join()方法加入的线程执行完成后它才会继续运行。
【案例】
class EmergencyThread implements Runnable{
public void run(){
for(int i=1;i<6;i++){
System.out.println(Thread.currentThread().getName()+"输入: "+i);
try{
Thread.sleep(500);
}catch(InterruptedException e){
e.printStackTrace();
}
}
}
}
public class ex05 {
public static void main(String[] args)throws Exception{
//创建线程
Thread t = new Thread(new EmergencyThread(),"线程一");
t.start();
for(int i=1;i<6;i++){
System.out.println(Thread.currentThread().getName()+"输入: "+i);
if(i==2){
t.join(); //调用join()方法
}
Thread.sleep(500); //线程休眠500毫秒
}
}
}
【运行结果】
两个线程的循环体中都调用了Thread的sleep(500)方法,以实现两个线程的交替执行。当main线程中的循环变量为2时,调用t线程的join()方法,这时,t线程就会“插队”优先执行。从运行结果可以看出,当main线程输出2以后,线程一就开始执行,直到线程一执行完毕,main线程才继续执行。
5. 多线程的同步
多线程的并发执行可以提高程序的效率,但是,当多个线程去访问同一个资源时,也会引发一些安全问题。例如,当统计一个班级的学生数目时,如果有同学进进出出,则很难统计正确。为了解决这样的问题,需要实现多线程的同步,即限制某个资源在同一时刻只能被一个线程访问。
5.1 线程安全问题
前面讲解的售票案例,极有可能碰到“意外”情况,如一张票被打印多次,或者打印出的票号为0甚至负数。这些“意外”都是由多线程操作共享资源ticket所导致的线程安全问题。接下来对售票案例进行修改,模拟四个窗口出售10张票,并在售票的代码中使用sleep()方法,令每次售票时线程休眠10毫秒。
【案例】
//定义SaleThread类实现Runnable接口
class SaleThread implements Runnable{
private int tickets=10; //10张票
public void run(){
while (tickets>0){
try{
Thread.sleep(10);
}catch(InterruptedException e){
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"---卖出的票"+(tickets--));
}
}
}
public class ex06 {
public static void main(String[] args){
SaleThread saleThread = new SaleThread(); //创建SaleThread对象
//创建并开启4个线程
new Thread(saleThread,"线程一").start();
new Thread(saleThread,"线程二").start();
new Thread(saleThread,"线程三").start();
new Thread(saleThread,"线程四").start();
}
}
【运行结果】
在运行结果中,最后打印售出的票出现了0和负数,这种现象是不应该出现的,因为售票程序中只有当票号大于0时才会进行售票。运行结果中之所以出现了负数的票号是因为多线程在售票时出现了安全问题。
出现这样的安全问题的原因是在售票程序的while循环中添加了sleep()方法,由于线程有延迟,当票号减为1时,假设线程1此时出售1号票,对票号进行判断后,进入while循环,在售票之前通过sleep()方法让线程休眠,这时线程二会进行售票,由于此时票号仍为1,因此线程二也会进入循环,同理,四个线程都会进入while循环,休眠结束后,四个线程都会进行售票,这样就相当于将票号减了四次,结果中出现了0、-1、-2这样的票号。
5.2 同步代码块
线程安全问题其实就是由多个线程同时处理共享资源所导致的,要想解决线程安全问题,必须得保证在任何时刻只能有一个线程访问共享资源。具体示例如下:
while (tickets>0){
try{
Thread.sleep(10);
}catch(InterruptedException e){
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"---卖出的票"+(tickets--));
}
为了实现这种限制,Java中提供了同步机制。当多个线程使用同一个共享资源时,可以将处理共享资源的代码放在一个使用synchronized关键字修饰的代码块中,这个代码块被称作同步代码块。使用synchronized关键字创建同步代码块的语法格式如下:
synchronized(lock){
操作共享资源代码块
}
上面的格式中,lock是一个锁对象,它是同步代码块的关键。当某一个线程执行同步代码块时,其他线程将无法执行当前同步代码块,会发生阻塞,等当前线程执行完同步代码块后,所有的线程开始抢夺线程的执行权,抢到执行权的线程将进入同步代码块,执行其中的代码。循环往复,直到共享资源被处理完为止。这个过程就好比一个公用电话亭,只有前一个人打完电话出来后,后面的人才可以打。
下面将售票的代码放到synchronized区域中
//定义SaleThread类实现Runnable接口
class SaleThread implements Runnable{
private int tickets=10; //10张票
Object lock = new Object(); //定义任意一个对象,用作同步代码块的锁
public void run(){
while (true){
synchronized(lock){ //定义同步代码块
try{
Thread.sleep(10);
}catch(InterruptedException e){
e.printStackTrace();
}
if(tickets>0){
System.out.println(Thread.currentThread().getName()+"---卖出的票"+(tickets--));
}
else{ //如果tickets小于0,跳出循环
break;
}
}
}
}
}
public class ex06 {
public static void main(String[] args){
SaleThread saleThread = new SaleThread(); //创建SaleThread对象
//创建并开启4个线程
new Thread(saleThread,"线程一").start();
new Thread(saleThread,"线程二").start();
new Thread(saleThread,"线程三").start();
new Thread(saleThread,"线程四").start();
}
}
【运行结果】
上述代码中,将有关tickets变量的操作全部都放到同步代码块中。为了保证线程的持续执行,将同步代码块放在死循环中,直到ticket<0时跳出循环。从运行结果可以看出,售出的票不再出现0和负数的情况,这是因为售票的代码实现了同步,之前出现的线程安全问题得以解决。运行结果中并没有出现线程二和线程三售票的语句,出现这样的现象是很正常的,因为线程在获得锁对象时有一定的随机性,在整个程序的运行期间,线程二和线程三始终未获得锁对象,所以未能显示它们的输出结果。
5.3 同步方法
上述代码中,将有关tickets变量的操作全部都放到同步代码块中。为了保证线程的持续执行,将同步代码块放在死循环中,直到ticket<0时跳出循环。从运行结果可以看出,售出的票不再出现0和负数的情况,这是因为售票的代码实现了同步,之前出现的线程安全问题得以解决。运行结果中并没有出现线程二和线程三售票的语句,出现这样的现象是很正常的,因为线程在获得锁对象时有一定的随机性,在整个程序的运行期间,线程二和线程三始终未获得锁对象,所以未能显示它们的输出结果。
synchronized 返回值类型 方法名([参数1,...]){}
被synchronized修饰的方法在某一时刻只允许一个线程访问,访问该方法的其他线程都会发生阻塞,直到当前线程访问完毕后,其他线程才有机会执行该方法。
//定义SaleThread类实现Runnable接口
class ticket1 implements Runnable{
private int tickets=10; //10张票
//Object lock = new Object(); //定义任意一个对象,用作同步代码块的锁
public void run(){
while (true){
saleTicket(); //调用售票方法
if(tickets<=0){
break;
}
}
}
// 定义一个同步方法saleTicket()
private synchronized void saleTicket(){
if(tickets>0){
try{
Thread.sleep(10); //经过此处的线程休眠10毫秒
}catch(InterruptedException e){
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"---卖出的票"+(tickets--));
}
}
}
public class ex06 {
public static void main(String[] args){
ticket1 saleThread = new ticket1(); //创建ticket1对象
//创建并开启4个线程
new Thread(saleThread,"线程一").start();
new Thread(saleThread,"线程二").start();
new Thread(saleThread,"线程三").start();
new Thread(saleThread,"线程四").start();
}
}
【运行结果】
上述代码中,第12~23行代码将售票代码抽取为售票方法saleTicket(),并用synchronized关键字把saleTicket()修饰为同步方法,然后在第6行代码中调用saleTicket()。从图中所示的运行结果可以看出,同样没有出现0号和负数号的票,说明同步方法实现了和同步代码块一样的效果。