Java中的多线程与同步

Java 专栏收录该内容
10 篇文章 0 订阅

一、进程与线程

         进程是可并发执行的程序在一个数据集上的一次执行过程,它是系统进行资源分配的基本单位。
         线程为进程所有,作为调度执行的基本单位,一个进程可以有一个或多个线程,他们共享所属进程所拥有的资源。

二、为什么要引入进程与线程

         要探索这个问题答案之前,需要先了解并发执行。并发执行是为了增强计算机系统的处理能力和提高资源利用率,而采取的一种可以同时操作的技术,它可以被总结为:一组在逻辑上互相独立的程序或程序段在执行过程中其执行时间在客观上互相重叠。然而实际上并没有真正的同时执行,因为CPU并不能同时执行多条指令。
         由于并发执行的间断性、失去封闭性以及不可再现性,因此引入了进程,以便从变化的角度、动态的分析研究程序的并发执行过程。
         20世纪80年代中期,人们不满足与以进程为单位去解决竞争处理器的问题,所以提出了比进程更小的、能独立运行的基本单位:线程,线程可以进一步提高程序并发执行的程度,降低并发执行的时空开销。将之前进程的功能进行分离,即将资源申请与调度执行分开。所以就有了上述中进程与线程的描述。

三、Java中的进程

       当通过java命令运行一个Java程序时,就启动了一个Java虚拟机进程,Java虚拟机进程从启动到终止的过程,成为Java虚拟机的生命周期。当程序正常执行结束、程序在执行中因为出现异常或错误而异常终止、程序执行System.exit()方法以及由于操作系统出现错误而导致Java虚拟机进程终止时, Java虚拟机将结束生命周期。
       当Java虚拟机处于生命周期中时,它的总任务就是运行Java程序,Java程序从开始到终止的过程为程序的生命周期,它和Java虚拟机的生命周期一致。
       

四、Java线程的运行机制

        每当用java命令启动一个Java虚拟机进程时,Java虚拟机都会创建一个主线程,该线程从程序入口main()方法开始执行。在Java虚拟机进程中,执行程序代码的任务是由线程来完成的,每个线程都有一个独立的程序计数器(PC寄存器)和方法调用栈(method invocation stack)。

  •        程序计数器:当线程执行下一个方法时,程序计数器指向方法区中下一条要执行的字节码指令。
  •        方法调用栈:简称方法栈,用来跟踪线程运行中一系列的方法调用过程,栈中的元素成为栈帧。每当线程调用一个方法的时候,就会向方法栈压入一个新帧。帧用来存储方法的参数、局部变量和运算过程中的临时数据。
          栈帧由以下三个部分组成:
  •        局部变量区:存放局部变量和方法参数
  •        操作数栈:是线程的工作区,用来存放运算过程中生成的临时数据。
  •         栈数据区:为线程执行指令提供相关的信息,包括如何定位到位于堆区和方法区的特定数据,以及如何正常退出方法或者异常终端方法。
public class Sample{
	private int a = 0;
	public int method(){
                int b;
		a++;
		return a;
	}
	
    public static void main(String args[]){
		Sample s =new Sample();
	        s.method();
                System.out.println(a);
    }
}

    以上面为例,简单介绍线程的运行过程:
        当用java命令运行以上程序时,首先会启动一个Java虚拟机进程,然后java虚拟机进程会创建一个主线程,主线程有它自己一个独立的程序计数器和方法调用栈。
       该线程从main()方法开始运行,由方法调用栈的原理可知,首先会将main()方法的栈帧压入方法栈,其中main()方法的栈帧中存储了main()方法的参数、局部变量和运算过程中的临时数据等,main()方法栈帧中的以上这些信息分别对应存放在局部变量区、操作数栈以及栈数据区。method()方法同理。
       当该主线程开始执行method()方法的的"a++"操作时,主线程能根据method()方法的栈帧的栈数据区中的有关信息,正确定位到堆区的Sample对象的实例变量a,并把它的值加1.当method()方法执行完毕后,它的栈帧就会从方法栈中弹出,它的局部变量b结束生命周期。main()方法的栈帧成为当前帧,主线程继续执行main()方法。
        

五、Java线程的创建和启动

        

创建线程有两种方式:

 1   扩展java.lang.Thread

 2   实现Runnable接口

 

1. 扩展java.lang.Thread类方式

   Thread类代表线程类,它主要有两个方法:

    Run() --- 线程运行时所执行的代码

    Start()--- 用于启动线程

   用户的线程类只需要继承Thread类,重写run()方法,将该线程所要执行的代码放入run()方法中。

   举例1:(多个线程  都有自己的局部变量)

public class MyThread extends Thread{
       public void run(){
          for(int a=1;a<6;a++){
              System.out.println(currentThread().getName()+":"+a);
              try{
                  sleep(100);  
              }catch(InterruptedException e){
                   System.out.println(e);
              }
          }
       }
       public static void main(String args[]){
               MyThread myThread1 = new MyThread();
               MyThread myThread2 = new MyThread();
               myThread1.start();
               myThread2.start();
               myThread1.run();
      }
}

 

      当主线程执行main()方法时,会创建两个MyThread对象,然后启动两个MyThread线程,接着主线程开始执行第一个MyThread对象的run()方法(此处为调用方法,不要与线程混淆)。主线程、myThread1以及myThread2这三个线程都拥有自己的程序计数器和方法栈,在三个线程各自的方法栈中都有代表run方法的栈帧,在这个帧中存放了局部变量a,即每个线程都拥有自己的局部变量a,它们都分别从1增加到5

     上例中主线程调用MyThread run()方法,而新建线程的run()方法作用是实现该新建线程所要实现的功能,这违背了Thread类提供run()方法的初衷,因此在实际应用中不值得效法。

     举例2. (多个线程共享同一个对象的实例变量)

<p>public class MyThread extends Thread{</p><p>	private int a=0;</p><p>	public void run(){</p><p>		for(a=1;a<6;a++){</p><p>			System.out.println(currentThread().getName()+":"+a);</p><p>			try{</p><p>				sleep(100);  </p><p>			}catch(InterruptedException e){</p><p>				System.out.println(e);</p><p>			}</p><p>		}</p><p>	}</p><p>	public static void main(String args[]){</p><p>		MyThread myThread1 = new MyThread();</p><p>		myThread1.start();</p><p>		myThread1.run();</p><p>	}</p><p>}</p>

 
  
 

运行以上程序时,主线程和MyThread线程都会执行MyThread对象的run()方法,但他们都会操纵同一个实例变量,如下图为程序运行可能的一种结果:

 

可以发现,多个线程共享同一个对象的实例变量与多个线程使用自己的局部变量的结果是显然不同的,这两个线程会轮流给变量a增加一,不过奇怪的事情发生了,不是说这两个线程轮流对变量a进行加一操作吗,怎么在前两行的打印语句中main线程和Thread-0线程输出了同样的结果,这就引出了多线程的数据保护和同步机制。

     

2. 实现Runnable接口方式

   Java的单继承规则限制了类的灵活性扩展,如果一个类继承了Thread类,就不能再继承其他的类,为了解决这一问题,Java提供了java.lang.Runnable接口,如下例:

 

 

public class MyThread implements Runnable{
        private int a=0;
        public void run(){
             for(a=1;a<6;a++){
                  System.out.println(Thread.currentThread().getName()+":"+a);
                  try{
                      Thread.sleep(100);  
                  }catch(InterruptedException e){
                      System.out.println(e);
                  }
             }
        }
        public static void main(String args[]){
             MyThread myThread1 = new MyThread();
             Thread t1 = new Thread(myThread1);
             t1.start();
             t1.run();
        }
}


 

同样这是多个线程共享同一个对象的实例变量。

关于接口的原理和使用规则,另专门深入记录学习。接口是一个很好的拓展功能实现方式,最近一次使用是在Android自定义View控件模板时,对其中的控件属性设置通过一个接口对外开放。

 

六、线程的状态切换

 

1.新建状态(New

      条件:通过New语句创建

      特点:仅在堆区中被分配了内存

2.就绪状态(Runnable

      条件:当一个线程对象创建后,其他线程调用它的start()方法

      特点:Java虚拟机会为它创建方法调用栈和程序计数器,该状态线程位于可运行池中, 等待获得CPU的使用权。

3.运行状态(Running

      条件:Runnable状态的线程抢占到CPU进入Running状态(只有Runnable状态才可转到运行状态)

      特点:独占一个CPU

4.阻塞状态(Blocked

       阻塞状态具体可分以下三种情况:

       ① Blocked in object’s wait pool  (位于对象等待池中的阻塞状态)

               条件: wait()

       ② Blocked in object’s lock pool  (位于对象锁池中的阻塞状态)

               条件:等待获取同步锁时

       ③ Otherwise Blocked (其他阻塞状态)

               条件:sleep()  join()  I/O请求(System.out.println()System.in.read())

5.死亡状态(Dead

       条件:退出run()方法  (正常执行完退出 或 遇到异常退出)

       特点:不会对其他线程造成影响

 

(之后补上线程状态切换图解)

七、线程调度

      所谓多线程的并发运行,只是从宏观上看。实际上一个CPU在任意时刻只能执行一条机器指令,所以除非你的计算机有多个CPU,否则它永远实现不了字面意思上理解的“同时运行”。在操作系统学习中有作业调度、进程调度以及线程调度,而在JavaJava虚拟机是一个进程,所以Java中的调度单位就是线程。

      不难想到,负责Java线程调度的就是Java虚拟机进程。结合线程的状态切换我们自己就可以推想到:Java虚拟机按照特定的调度算法为处于Runnable状态的多个线程分配CPU的使用权。而需要注意的时,线程的调度不仅取决于Java虚拟机,还依赖于操作系统,所以线程的调度不是跨平台的。

      如果我们对线程的调度不加管理,那多线程的运行时序是无规律可循的,实际上就算我们对线程调度进行管理,它们的运行时序也不能百分之百确定的,比如让一个线程给另外一个线程运行的机会,可采取以下办法:

① 调整线程优先级

         Java中提供了10个优先级,取值范围是整数1~10(关于Java优先级与操作系统优先级之间的映射另外深入学习),Therad类中有3个静态常量:

         l MAX_PRIORITY:取值为10,表示最高优先级

         l MIN_PRIORITY:取值为1,表示最低优先级

         l NORM_PRIORITY:取值为5,表示默认的优先级

         而设置优先级的方法是通过Thread类的setPriority(int)方法

         所有处于就绪状态的线程根据优先级存放在可运行池中,优先级低的线程获得较少的运行机会,优先级高的线程获得较多的运行机会。

 

② 让Running状态线程调用Thread.sleep()方法

        假如让一个线程调用sleep()方法,你可以给它一个精确的数值1000毫秒,但是从开始执行sleep(1000)方法到该线程再一次进入Running状态之间的时间确并不是精确的1000毫秒,因为1000毫秒过后它进入Runnable状态,而从Runnable状态到Running状态的时间是不确定的,这个时间取决于很多因素,比如CPU运算速度,调度算法,当前正在运行的进程或线程等。

 

③ 让Running 状态线程调用Thread.yield()方法

        而最能证明线程管理的不确定性就是Thread.yield()方法了,yield翻译过来是“屈服”的意思,实际上我们可以把它理解为假装屈服,因为你稍微慢一点,下一时刻抢占到CPU的还可能是原来的线程。

         sleep() yield()方法都是Thread类的静态方法,都会使当前处于运行状态的线程放弃CPU,把运行机会让给别的线程。两者区别在于:

1.sleep () 方法会给其它线程运行的机会,而不考虑其他线程的优先级,而yield()方法只会给相同优先级或者更高优先级一个运行的机会。

2.当线程执行sleep()方法后将转到阻塞状态;当线程执行yield()方法后,将转到就绪状态。

3.sleep()方法声明抛出InterruptedException异常,而yield()方法没有声明抛出任何异常。所以sleep()语句要放在try-catch中。

4.sleep()方法比yield()具有更好的可移植性,在实际应用中不能只依靠yield()方法来提高程序的并发性能。

④ 让Running状态线程调用另一个线程的join()方法

        当前运行的线程可以调用另一个线程的join()方法,当前运行的线程将转到阻塞状态,直至另一个线程运行结束,它才会从阻塞状态转到就绪状态,获得运行机会。

八、后台线程

       概念:为其他线程提供服务的线程,也称为守护线程。Java虚拟机的垃圾回收线程就是典型的后台线程,它负责回收其他线程不再使用的内存。

       特点:后台线程与前台线程相伴相随,后台线程可能在前台线程执行完毕之前结束,如果前台线程运行结束后,加入后台线程还在运行,Java虚拟机就会终止后台线程。这又反应在后台线程的概念上,后台线程是为服务于前台线程而存在的,倘若没有前台线程在执行,后台线程也就没有了存在的必要。

       主线程在默认情况下是前台线程,由前台线程创建的线程在默认情况下也是前台线程;由后台线程创建的线程在默认情况下仍然是后台线程。

       将前台线程设置为后台线程:调用Thread类的setDaemon(true)方法。

       判断线程是否为后台线程:调用ThreadisDaemon()方法

       以下为一个后台线程定时执行任务的简单例子:

import java.util.Timer;
import java.util.TimerTask;
public class MyThread extends Thread{
      private int a;
      private static int count;
      public void start(){
          super.start();
          System.out.println(currentThread().getName()+"--background?--->"+currentThread().isDaemon());
          Timer timer = new Timer(true);
          TimerTask task = new TimerTask(){
                public void run(){
                    System.out.println(currentThread().getName()+"--background?--->"+currentThread().isDaemon());
                    while(true){
                             reset();
                             try{
                                 sleep(1000);
                             }catch(InterruptedException e){
                                  System.out.println(e);
                             }
                   }
               }
         };
         timer.schedule(task,10,500);
     }
     public void reset(){a=0;}
     public void run(){
           System.out.println(currentThread().getName()+"--background?-->"+currentThread().isDaemon());
           while(true){
                System.out.println(getName()+":"+a++);
                if(count++==100) break;
                yield();
           }
     }
     public static void main(String args[]){
         MyThread myThread = new MyThread();
         myThread.start();
     }
}

java.util.TimerTask类是一个抽象类,它实现了Runnable接口,在MyThread类的中重写的start()方法中通过匿名内部类的方式继承TimerTask类,它的run()方法表示定时器需要定时完成的任务。

java.util.Timer类本身不是线程类,它通过其中的schedule(TimerTask task, long delay, long period)方法利用线程来执行定时任务。上例中表示定时器将在10毫秒后开始执行task任务,以后每隔500毫秒重复执行一次task任务。

java.util.Timer类的构造方法有几种重载形式。上例中使用Timer(boolean isDaemon)形式的构造方法,isDaemon表示把Timer关联的线程设为后台线程。以下为可能的结果之一:


省略中间部分...


 

分析程序流程:

首先主线程创建一个MyThread实例,然后调用MyThread类中的start()方法,但是执行完这句后主线程并没有结束,因为MyThread类中重新了start()方法,所以主线程会执行MyThread类中的start()方法,此时myThread线程还未启动。

主线程在start()方法中执行Thread父类的start()方法,此时启动MyThread线程,MyThread线程开始执行MyThread类中的run()方法;主线程继续执行super.start()下面的语句,此时若不考虑java虚拟机为我们创建的垃圾回收线程,那么一共存在MyThread和主线程这两个线程,且由于MyThread线程是由主线程创建的,所以这两个线程都为前台线程。

主线程接着在TimerTask中通过匿名内部类的方式实现Runnable接口,创建了第三个线程Timer-0,由于Timer类的构造方法Timer(boolean isDaemon)传递进去的值为true,表示把与Timer相关联的线程设为后台线程,所以在timer.schedule()方法后,Timer-0线程就变成了后台线程,与此同时,主线程也执行完它所能执行的代码,就这样在光荣的创建了两个线程后,主线程寿终正寝了。

然后就剩下Thread-0线程和Timer-0这两个线程了,其中Thread-0是主线程亲生的,名副其实的主线程,但Timer-0线程被Timer(boolean isDaemon)改造过,Timer-0这个后台线程被改造成后台线程后存在的意义就是为前台线程服务,不服也不行,前台线程执行完了,你就必须跟着陪葬,当然如果你想先死,那没人拦着你。Java虚拟机允许你先死,但是不允许你比前台线程活得久。不过在此例中后台线程Timer-0无限循环,不会先死,没日没夜的为前台线程服务着,等着陪葬,这也对得起后台线程的另一个称号了:守护线程!

九、线程的同步

      

Java引入了同步机制,具体做法是将操纵数据的程序代码加上synchronized标记,被syschronized标记的代码块被称为同步代码块。

将代码块标记为同步代码块有以下两种方式:(这两种方式等级)

 1.  public  synchronized String pop() {...}

 2.  public  String pop(){

       Synchronized(this) {...}

     }

         很多初学者包括几天前的我都喜欢问一个问题:同步锁锁住的是什么?其实这样问并不利于理解同步锁的机制。以以下代码为例:

  

class Stack{
    public synchronized String pop(){...}
    public synchronized void push(){...}
}

       每个 Java 对象都有且只有一个同步锁,在任何时刻,最多只允许一个线程拥有这把锁。当一个 a 线程试图执行 Stack 对象中带有 synchronized(this) 标记的代码块时,这个线程必须首先获得 this 关键字引用的对象的锁,也就是 Stack 对象的锁。
假如这个锁已经被其他线程占用, Java 虚拟机就会把这个 a 线程放到 Stack 对象的锁池中, a 线程进入阻塞状态。在 Stack 对象的锁池中可能有许多等待锁的线程。等到其他线程释放了锁, Java 虚拟机会从锁池中随机取出一个线程,使这个线程拥有锁,并且转到 Runnable 状态。
       假如这个锁没有被其他线程占用, a 线程就会获得这把锁,开始执行同步代码块,一般情况下, a 线程只有执行完同步代码块,才会释放锁,使得其他线程能够获得锁。
现在我们再回到上面那个问题:同步锁锁住的是什么?
       答案:同步锁是属于对象的,这个对象的同步锁锁住的是这个对象中所有被 synchronized(this) 标记的同步代码块。我们不能只针对一个被锁住的代码块问锁住的是什么。
上例中,当 a 线程获得 Stack 对象的锁执行 pop() 方法时,这时 b 线程请求调用 Stack 对象的 push() 方法,能不能调用成功并执行呢?这时你明白为什么我说“同步锁锁住的是什么?”这个问题为什么不利于理解同步锁的机制了吧。
所谓线程之间保持同步,是指不同的线程在执行同一个对象的同步代码块时,因为要获得这个对象的锁而互相牵制。所以我们也要格外注意synchronized(this) 中的 this 指的是哪个对象。

      关于Java中线程的学习先到这里。

  以上若有差错,欢迎指出。




      
  • 1
    点赞
  • 0
    评论
  • 1
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值