操作系统-线程

在之前完成对进程的整理和理解后一直没来得及对线程的知识做进一步的梳理

趁着java考试的机会从应用的角度看一下线程到底和进程是什么关系,又是起到何种作用

1. 概念

1.首先重温一下进程的概念

程序:写出来的代码,也就是指令的集合,以文件形式存储在硬盘上

进程:操作系统中运行的程序肯定是一个动态的概念,或者说进行中的程序

进程的目的:实现并发执行环境,提高计算机系统性能。进程是程序在数据集合上的一次执行过程,是资源申请、调度、和独立运行的单位。

2.线程与进程的关系

进程是系统资源分配的单位;线程是CPU调度和执行的单位。

线程实质是进程中一个独立执行线索,一个进程至少包括一个线程。在支持线程的系统中,进程执行时,真正完成任务的是线程。

线程由称为轻量级进程,它和进程一样拥有独立的执行控制,区别在于线程 没有独立的存储空间,而是和所属进程中的其他线程共享一个存储空间。线程之间的切换只需要切换执行流程和相关的局部变量,这种切换要比进程之间的切换效率高得多。

一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

一个线程不能独立的存在,它必须是进程的一部分。一个进程一直运行,直到所有的非守护线程都结束运行后才能结束。

3.线程的概念模型

  • 虚拟CPU:封装在java.lang.Thread类中,由Thread的对象封装模拟实现线程运行需要的CPU。
  • CPU所执行的代码:传递给Thread类,由Thread的对象执行。
  • CPU所处理的数据:传递给Thread类,由Thread的对象处理

2. 线程的创建

  • 继承java.lang.Thread类
  • 实现java.lang.Runnable接口
  • 通过 Callable 和 Future 创建线程

2.1 继承Thread类

步骤:

  • 自定义线程类继承Thread类
  • 重写run()方法,编写线程执行体
  • 创建线程对象,调用start()方法启动线程

线程会交替执行,多线程程序每次的执行结果基本不会完全相同,因为其运行结果取决于系统中线程的调度情况以及线程获得执行权时执行时间片的长短。

2.2 实现Runnable接口

步骤:

  • 线程类MyThread实现接口Runnable(中的runing方法)
  • 创建的线程类在实例化线程类对象后,还必须通过Thread类进行封装

2.3 两种实现方式的区别

先说一下创建线程类实现Runnable的好处:

  • 实例:实现模拟铁路售票系统,通过四个售票点发售某日某次列车的100张车票,一个售票点用一个线程表示。

  1. 继承Thread类实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class SellTicket extends Thread{
    private int ticket = 100;
    public void run(){
    while (true){
    if(ticket<0){
    System.out.println("is selling ticket" + ticket--);
    }else{
    break;
    }
    }
    }
    }

    然后进行四次SellTicket类的实例化,每一次都执行start方法启动。

    输出结果:每个ticket票号都被打印了4次,这显然不满足要求

    原因:多个线程处理同一个资源,一个资源只能对应一个对象,上面的程序创建了四个SellTicket对象,就等于创建了4个资源,每个线程都在独自处理各自的资源

  2. 实现Runnable接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class SellTicketSystem{
    public static void main(String[] args){
    SellTicket t = new SellTicket();
    new Thread(t).start();
    new Thread(t).start();
    new Thread(t).start();
    new Thread(t).start();
    }
    }
    class SellTicket implements Runnable{
    private int ticket = 100;
    public void run(){
    while (true){
    if(ticket<0){
    System.out.println("is selling ticket" + ticket--);
    }else{
    break;
    }
    }
    }
    }

    运行结果避免了上例中的问题,虽然同样创建了4个线程,但是每个线程调用同一个SellTicket对象中的run()方法。访问的是同一个对象中的变量ticket实例。

3. 线程的状态

3.1线程状态转换

一、线程的生命周期

  • 新建(New)状态:

    使用 new 关键字和 Thread 类或其子类建立一个线程对象后,该线程对象就处于新建状态。它保持这个状态直到程序 start() 这个线程。

  • 就绪(Runnable)状态:

    当线程对象调用了start()方法之后,该线程就进入就绪状态。就绪状态的线程处于就绪队列中,要等待JVM里线程调度器的调度。

  • 运行(Running)状态:

    如果就绪状态的线程获取 CPU 资源,就可以执行 run(),此时线程便处于运行状态。处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态。

  • 阻塞(Blocked)状态:

    如果一个线程执行了sleep(睡眠)、suspend(挂起)等方法,失去所占用资源之后,该线程就从运行状态进入阻塞状态。在睡眠时间已到或获得设备资源后可以重新进入就绪状态。可以分为三种:

    • 等待阻塞:运行状态中的线程执行 wait() 方法,使线程进入到等待阻塞状态。
    • 同步阻塞:线程在获取 synchronized 同步锁失败(因为同步锁被其他线程占用)。
    • 其他阻塞:通过调用线程的 sleep() 或 join() 发出了 I/O 请求时,线程就会进入到阻塞状态。当sleep() 状态超时,join() 等待线程终止或超时,或者 I/O 处理完毕,线程重新转入就绪状态。
  • 死亡(Dead)状态:

    一个运行状态的线程完成任务或者其他终止条件发生时(run()方法或main()方法运行结束后,该线程就切换到终止状态。

3.2 线程的状态转换方法

一、优先级

Thread线程类提供的优先级控制方法:setPriority(int newPriority),getPriority()分别用来设置和获得线程优先级

Thread线程类提供的优先级常量:MAX_PRIORITY(10),MIN_PRIORITY(1),NORM_PRIORITY(5)

线程的优先级用1-10之间的一个整数表示,数值越大优先级越高,线程的默认优先级为5。

注意:虽然定义了优先级,但是java语言并没有规定低优先级的线程就一定要让优先级高的线程先运行,且知道线程结束自身才能运行。所以不能在程序中使用设置优先级的方式来控制线程。

二、串行化join()方法

应用场景:当一个线程的运行需要另一个线程运行的结果时,比如当线程A调用了线程B的join()方法时,那么直到线程B执行完成后线程A才能执行。

三、休眠sleep()方法

线程休眠

四、线程中断yield()方法

在Java的多线程中yield()方法的作用是放弃当前线程获取CPU的执行权,将让其它的线程去获取。但这个是不固定的,有可能刚放弃CPU的执行权,又被CPU执行了。

五、线程终止stop()方法

Thread类定义了stop()方法,但是stop方法存在不安全因素,不推荐使用,一般可以通过标识变量等价实现stop()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyThread extends Thread{
private boolean flag = false;
public void run(){
int i = 0;
while(true){
if(flag){
break;
}
System.out.println(this.getName()+":"+(i++));
}
}
public void shutdown(){
this.flag = true;
}
}

这里的flag即作为标识变量来实现shutdown方法等价stop方法的效果

4. 线程同步

4.1 背景:临界资源问题

同一时刻只允许一个线程访问的资源,称为临界资源;处理临界资源的代码称为临界区。

比如栈操作,出栈和入栈如果发生在不同线程:如果执行入栈操作的t1线程执行到数据存入但指针还没有增加的时刻中断了(比如执行了yield()或sleep()方法), 此时执行出栈操作的t2线程执行的话,指针指向的位置将不会被弹出。

例子中的index指针和数组中的元素都是临界资源。入栈方法和出栈方法都是临界区。

为了解决临界资源问题,保证共享数据操作的完整性,java语言引入了互斥锁的概念。java中的每个对象都有一个互斥锁(有且只有一个),一旦加长锁后(获得该对象的访问权),在临界区代码执行完前,只能有一个线程访问临界区。

使用关键字synchronized给对象加锁,synchronized可以修饰方法,也可以修饰代码块。

修饰代码块的语法格式:

1
2
3
synchronized (对象){
//访问临界资源的代码
}

“()”中的对象可以是任意对象,如果线程要执行synchronized块中的代码(访问临界资源),首先应拿到synchronized块同步对象的锁,否则不能访问synchronized块中的代码。线程一旦获得锁,直到执行完所有synchronized块中的代码才释放对象锁,因为对象锁有且只有一把,保证了一个线程执行同步块代码过程中其他线程不能执行同步代码块,确保数据一致性。

修饰方法的使用方式:

同步对象是this。通过线程类SellTicketSystem2代码,将访问临界资源的代码放在一个方法,然后使用synchronized修饰该方法。

4.2 线程死锁

并发运行的多个线程彼此等待对方加锁的资源,都无法运行的状态称为死锁。产生死锁的原因

产生死锁的原因主要是系统资源不足,进程运行推进的顺序不合适,资源分配不当等。

5. 线程通信

多线程程序中的线程没有任何联系,相互之间是孤立的,失去了多线程的意义。java中多线程通信的主要方法是:wait()、notify()、norifyAll()。为了在多线程并发运行时避免死锁,线程阻塞时应该尽可能释放占用的资源,使得其他线程获得运行的机会。