第3章 并发程序基础 l 3.1 程序,进程和线程 1.程序(program) 程序是为了完成某特定任务的一组计算机能识别和执行的指令的集合。程序被存储在磁盘或其他的数据存储设备中。也就是说,程序是静态的代码。 2.进程(process) 进程是程序的一次执行过程。进程是动态的,它对应了从程序代码加载、执行到执行结束的一个完整的过程,也就是进程创建、运行到消亡的过程。 3.线程(thread) 线程是一个比进程更小的执行单位。线程又称轻进程,是进程内的一个相对独立的执行流。 程序的并发执行,是指由操作系统将系统资源分配给各个进程,多个进程在CPU上交替运行。 当系统交替运行不同的任务时,需要完成对应进程的切换。 进程切换所需的系统开销是比较大的。因为每个进程分配得到的资源不同,都需要切换。 进程内部的多线程之间共享进程资源,进程内部的线程切换不涉及大部资源的切换,执行并发任务的开销小,效率高。 通过多线程程序设计,可以将一个程序任务划分为多个可并发执行的子任务,可以提高整个程序的执行效率和系统资源的利用率。 l 3.2 创建线程 Java程序启动时,Java虚拟机(JVM)会自动创建一个主线程来执行该程序中的main()方法。 用户编写的线程一般都是除了主线程之外的其他线程。 JVM会在主线程和其他线程之间切换,这些线程将被轮流执行。 创建一个线程通常有两种方法: 继承Thread类, 实现Runnable接口。 3.2.1 Thread类 3.2.2 继承Thread类 通过继承Thread类来创建并启动线程的步骤如下: (1)定义一个子类继承Thread类,并重写run()方法 (2)创建子类的实例,即实例化线程对象 (3)调用线程对象的start()方法启动该线程 3.2.3 实现Runnable接口 通过实现Runnable接口创建、启动线程的基本步骤如下: (1)定义一个实现了Runnable接口的类,重写run()方法。 (2)实例化一个该类的类对象;以该对象为参数创建一个Thread类对象。 (3)调用Thread类对象的start()方法,启动线程。 3.2.4 两种创建线程的方法比较 例3-3 继承Thread类创建两个线程模拟2个窗口售票。 实现Runnable接口相对于继承Thread类来说,有如下几个显著的优势: (1)适合多个相同程序代码的线程去处理同一资源的情况,把线程的执行同程序的代码、数据有效分离,较好地体现了面向对象的设计思想。 (2)可以避免由于Java的单继承特性带来的局限。使用Runnable接口比使用Thread的子类更具有灵活性。 (3)增强了程序的健壮性 l 3.3 线程的状态 Java线程在运行的生命周期中可能处于6种不同的状态: 初始状态(NEW) 可运行状态(RUNNABLE) 阻塞状态(BLOCKED) 等待状态(WAITING),超时等待状态(TIME_WAITING) 终止状态(TERMINATED) 在给定的一个时刻,线程只能处于其中的一个状态。 可以使用getState()方法确定一个线程的当前状态。 l 3.4 线程的属性 3.4.1 线程的优先级 3.4.2 守护线程 3.4.3 未捕获异常处理器 3.4.1 线程的优先级 优先级决定了线程被CPU执行的优先顺序。默认情况下,一个线程继承它的父线程的优先级。 Java将线程的优先级分为10个等级,分别用1~10之间的数字表示。数字越大表明线程的优先级别越高。 Thread类有三个关于线程优先级的静态常量: (1)MIN_PRIORITY 表示最小优先级,其值为1。 (2)MAX_PRIORITY表示最高优先级,其值为10。 (3)NORM_PRIORITY 表示普通优先级,其值为5。 当一个线程对象被创建时,其默认的线程优先级是5。 假设threadObj是一个Thread类对象,可以这样获得和设置优先级: thread0bj.getPriority(); //获取的优先级 thread0bj.setPriority(6); //将thread0bj的优先级设为6 例3-5 创建多个线程对象,获取其默认优先级后更改线程的优先级,并显示输出。 3.4.2 守护线程 守护线程是比较特殊的一种低级别线程,一般被用于在后台为其他线程提供服务。 守护线程和普通线程的区别在于它并不是应用程序的核心部分。 调用Thread类中的方法isDaemon()可以判断一个线程是否是守护线程。调用Thread类中的setDaemon(true)方法可以将一个线程为转换成守护线程,但是有一点值得注意:必须在线程启动之前调用setDaemon()方法。 守护线程应该永远不去访问固有资源(文件、数据库),因为它会在任何时候发生中断。 3.4.3 未捕获异常处理器 线程的run()方法不允许抛出任何checked exception(被检测的异常)但是线程依然有可能抛出 unchecked exception(如运行时异常),当此类异常抛出时会导致该线程的终止。主线程和其他线程完全无法catch到这个异常。 异常逃逸出任务的run()方法会向外传播到控制台报错。 例3-6 线程抛出 unchecked exception 在线程死亡之前,异常会被传递到一个用于未捕获异常的处理器。 未捕获异常处理器可以在run()方法外部捕获线程抛出的未捕获异常。 该处理器实现一个Thread.UncaughtExceptionHandler接口的类。这个接口只有一个方法即: void uncaughtException(Thread t,Throwable e) 可以用setUncaughtExceptionHandler()方法为任何线程安装一个处理器。 l 3.5 线程的常用操作 3.5.1 线程休眠(sleep) sleep()方法是使一个线程的执行暂时停止的方法,暂停的时间由给定的毫秒数决定。 sleep()方法的语法格式如下: public static void sleep(long millis) 参数millis必选,该参数以毫秒为单位设置线程的休眠时间。 如果任何一个线程中断了当前线程的休眠,该方法将抛出InterruptedException异常对象。所以,在使用sleep()方法时必须捕获该异常。 例: try { Thread.sleep(1000); //使线程休眠1s } catch (InterruptedException e) { //捕获异常 e.printStackTrace(); //输出异常信息 } 3.5.2 谦让(yield) yield()方法可以使当前执行的线程让出CPU给其他线程执行。此时,该线程仍处于可运行状态,系统选择其他相同或更高优先级线程执行。若无其他相同或更高优先级线程,yield()方法什么也不做,则该线程继续执行。 yield()方法的语法格式如下: public static void yield() 注意:实际运行过程中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。 3.5.3 等待线程结束(join) join()方法能够使当前执行的线程暂停,直到被 join()方法加入的线程执行完成后它才会继续执行。 join()方法的语法格式如下: public final void join() 如果有一个线程A正在运行,用户希望插入一个线程B,并且要求线程B执行完毕,然后再继续线程A,此时可以使用到B.join()方法来完成这个需求。例: public class A extends Thread{ Thread B; run(){ ... B.join(); //在线程A中执行线程B ... } } 3.5.4 线程中断(interrupt) interrupt()方法的作用是中断线程。 实际上只是给该线程设置一个中断标志。 interrupt()方法的语法格式如下: public void interrupt() 注意:interrupt()不能中断在运行中的线程,它只能改变中断状态而已。 例3-10 使用interrupt()方法设定中断标记。 当一个线程调用sleep()方法处于休眠状态时,另一个占有CPU资源的线程可以让休眠的线程调用interrupt()方法,使休眠的线程发生InterruptedException异常,从而使线程结束休眠,重新排队等待CPU资源。 例3-11 使用interrupt()方法唤醒休眠的线程。 3.5.5 线程退出 stop()方法可以终止当前线程的执行,使线程进入死亡状态。 stop()方法是不安全的,而且该方法已被弃用。 例如一个线程被stop()方法停止执行了,会立即释放该线程所持有的所有的锁,执行了一半的线程可能导致数据得不到同步的处理,出现数据不一致的问题。 l 3.6 线程同步控制 3.6.1 线程同步的必要性 如果两个线程同时对同一个变量进行读(取值)和写(赋值)操作,那么就有可能造成数据读和写的不正确 可以在程序中调用Thread.sleep(long millis)方法来刻意造成线程间的这种切换。 3.6.2 多线程同步机制的实现 Java线程同步通常采用以下三种方式: (1)同步代码块 (2)同步方法 (3)同步锁 同步代码块、同步方法都要用到synchronized关键字。 synchronized关键字 synchronized是Java提供的强制原子性的内置锁机制。 每个Java对象都可以隐式地扮演一个用于同步的锁的角色;这些内置的锁被称作内部锁(intrinsic locks)或监视器锁(monitor locks)。 内部锁在Java中扮演了互斥锁(mutual exclusion lock)的角色。 synchronized关键字通常用来修饰代码块。 一个synchronized块有两部分:锁对象的引用,以及这个锁保护的代码块。 同一时间,只能有一个线程可以运行特定锁保护的代码块, 执行线程进入synchronized块之前会自动获得锁;线程在放弃对synchronized块的控制时自动释放锁。 3.6.2.1 同步代码块 将需要同步访问控制的代码语句放入一个同步块中 语法格式如下: synchronized(syncObject){ ... //需要同步访问控制的代码 } 注意:任何时刻只能有一个线程可以获得对同步监视器的锁定;当同步代码块执行完成后,该线程会释放对该同步监视器的锁定。 一般都可将当前对象this设置成同步对象 13.2 基于锁的同步控制 同步控制是并发程序必不可少的重要手段,它协调线程的运行,使得多线程的行为可以预见。 锁是同步控制的最主要的手段,它决定了一个线程是否可以访问临界区资源,使得线程能够串行地访问它所保护的资源,从而保证线程的安全性。 3.6.2.2 同步方法 可以使用synchronized关键字将一个方法声明成同步方法。 语法格式如下: 访问修饰符synchronized 返回类型 方法名称([参数列表]){ ... //方法体 } 同步方法也有缺陷:如果将一个运行时间比较长的方法声明成synchronized将会影响效率。 3.6.2.3 同步锁(Lock) 同步锁Lock是一种更强大的线程同步机制,通过显式定义同步锁对象来实现线程同步。 同步锁提供了比同步代码块、同步方法更广泛的锁定操作,实现更灵活。 ReentrantLock类是常用的可重入同步锁,该类对象可以显式地加锁、释放锁。该类定义在java.util.concurrent.locks.ReentrantLock中,使用前必须先导入。 加锁与释放锁方法如下: public void lock(): 加同步锁。 public void unlock(): 释放同步锁。 通常使用ReentrantLock的步骤如下: (1)定义一个ReentrantLock锁对象,该对象是final常量; private final ReentrantLock lock = new ReentrantLock(); (2)在需要保证线程安全的代码之前增加“加锁”操作; lock.lock(); (3)在执行完线程安全的代码后“释放锁”。 lock.unlock( ); 注意:通常将释放锁lock.unlock()的操作放在finally语句中。这样的话,无论程序是否异常,都会释放锁。 l 3.7 线程通信:生产者和消费者问题 线程间不仅要求能同步地访问同一共享的资源,而且线程间还需要相互合作,彼此制约,这就要求它们之间能够相互通信。 生产者和消费者问题就是一个多线程同步问题的经典案例。 线程通信可以使用Object类中定义的wait()、notify()和notifyAll()这3个方法,使线程之间相互进行事件通知。 wait()方法使当前线程暂停执行,去等待某一事件的发生,线程的状态由可执行状态转为等待状态。随后,当等待的事件发生时,一般通过调用notify()或者notifyAll()方法来唤醒该进程,使该进程继续执行。 notify()方法用来唤醒一个处于阻塞状态的线程,任何一个已经满足了被唤醒条件的线程都可能被唤醒。 notifyAll()方法则用于唤醒所有处理阻塞状态的线程。 注意:wait()、notify()和notifyAll()这3个方法都是Object类中的final方法,被所有的类继承且不允许重写。这3个方法只能在同步方法或者同步代码块中使用,否则会抛出异常。 l 3.8 Volatile 3.8.1 Java内存模型(JMM) 什么是内存模型? 内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节,对象最终是存储在内存里面的。由于CPU和内存运行速度的差距会导致整个系统性能的下降,因为CPU每次读写数据都要等待内存,所以在系统中通常会增设缓存来缓和CPU和内存读取速率不匹配的矛盾。但缓存的引入不能保证共享内存读写的正确性(原子性、可见性和有序性)。因此,人们定义了内存模型。内存模型是一个规范,这个规范能保证共享内存读写的正确性。 Java内存模型是内存模型在Java虚拟机(JVM)中的体现。 Java虚拟机(JVM)中存在一个主存区(Main Memory或Java Heap Memory),Java中所有变量都是存在主存中的,对于所有线程进行共享;而每个线程又存在自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝, 线程对所有变量的操作并非发生在主存区,而是发生在自己的工作内存中。 线程之间是不能直接相互访问的,变量在程序中的传递,是依赖主存来完成的。 而Java内存模型就作用于工作内存和主存之间数据同步过程。 Java内存模型主要目标是定义程序中各个共享变量的访问规则,也就是在Java虚拟机中将变量存储到内存以及从内存中取出变量这类的底层细节。通过这些规则来规范对内存的读写操作,保证了并发场景下的原子性、可见性和有序性。 并发场景下的原子性、可见性和有序性 1.原子性(Atomicity) 原子性是指一个或多个操作,要么全部不执行,要么全部执行且在执行过程中不被任何因素打断。 在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作。 在多线程环境下线程的随意切换会破坏原子性,可能会造成错误的执行结果。 因此,要保证多线程并发访问数据的正确性,就需要保证访问共享数据操作(如执行“i++”的过程的三个步骤)的原子性。 2.可见性(Visibility) 可见性(Visibility)是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上就能看到。 普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时主内存中可能还是原来的旧值,因此无法保证可见性。 要保证线程并发执行不读到脏数据,就要实现可见性。 3.有序性(Ordering) 有序性指的是程序按照指令代码的先后顺序执行。 在Java内存模型中,为了性能优化,允许编译器和处理器对指令进行重排。 JVM可以对它们在不改变数据依赖关系的情况下进行任意排序以提高程序性能。 这里所说的数据依赖关系仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不会被编译器和处理器考虑。 指令重排的行为在单线程环境下不会有任何问题,但是在多线程环境下程序就可能出现错误的执行结果。 总的来说,要想并发程序正确地执行,就必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。 3.8.2 volatile 与Java内存模型(JMM) 英文字典中volatile最常用的解释是“易变的,不稳定的”。 当你用volatile去申明一个变量时,就等于告诉了Java虚拟机,这个变量是“易变的,不稳定的”,极有可能会被某些线程修改。 JMM要确保所有线程看到这个变量的值是一致的。 volatile变量读写的内存语义如下: (1)当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程将从主内存中读取共享变量。 (2)当写一个volatile变量时,JMM会把该线程对应的本地中的共享变量值刷新到主内存。 上述内存语义可以保证volatile变量的可见性。 volatile变量具有synchronized的可见性,也保证了一定的有序性,但是不具备原子性。 volatile可以被看作是一种“程度较轻的synchronized”;它所能实现的功能也仅是 synchronized的一部分。 在某些情况下,如果读操作远远大于写操作,volatile变量还可以提供优于锁的性能优势。 要使volatile变量提供理想的线程安全,必须同时满足下面两个条件: (1)对变量的写操作不依赖于当前值。 (2)该变量没有包含在具有其他变量的不变式中。 多数编程情形都会与这两个条件的其中之一冲突,使得volatile变量不能像synchronized 那样普遍适用于实现线程安全。 3.8.3 Java内存模型的实现 1.JMM对原子性的保证 在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作。 如果要实现更大范围操作的原子性,可以通过synchronized或Lock来实现。 2.JMM对可见性的保证 JMM针对可见性问题,常见的保证方法有: (1)volatile关键字 (2)synchronized关键字 (3)Lock锁 3.JMM对有序性的保证 volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。 可以通过synchronized和Lock来保证有序性。很显然,synchronized和Lock保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
|