4.10.1 什么是线程?它与进程有什么区别?为什么要使用多线程
线程是指程序在执行过程中,能够执行程序代码的一个执行单元。
进程是指一段正在执行的程序。而线程有时也被称为轻量级进程,它是程序执行的最小单元,一个进程可以拥有多个线程,各个线程之间共享程序的内存空间(代码段、数据段和堆空间)及一些进程级的资源(例如打开的文件),但是各个线程拥有自己的栈空间。
在操作系统级别上,程序的执行都是以进程为单位的,而每个进程中通常都会有多个线程互不影响地并发执行,那么为什么要使用多线程呢?其实,多线程的使用为程序研发带来了巨大的便利,具体而言,有以下几个方面的内容:
1)使用多线程可以减少程序的响应时间。在单线程(单线程指的是程序执行过程中只有一个有效操作的序列,不同操作之间都有明确的执行先后顺序)的情况下,如果某个操作很耗时,或者陷入长时间的等待(如等待网络响应),此时程序将不会响应鼠标和键盘等操作,使用多线程后,可以把这个耗时的线程分配到一个单独的线程去执行,从而使程序具备了更好的交互性。
2)与进程相比,线程的创建和切换开销更小。由于启动一个新的线程必须给这个线程分配独立的地址空间,建立许多数据结构来维护线程代码段、数据段等信息,而运行于同一进程内的线程共享代码段、数据段,线程的启动或切换的开销比进程要少很多。同时多线程在数据共享方面效率非常高。
3)多 CPU 或多核计算机本身就具有执行多线程的能力,如果使用单个线程,将无法重复利用计算机资源,造成资源的巨大浪费。因此在多 CPU 计算机上使用多线程能提高 CPU 的利用率。
4)使用多线程能简化程序的结构,使程序便于理解和维护。一个非常复杂的进程可以分成多个线程来执行。
4.10.2 同步和异步有什么区别
在多线程的环境中,经常会碰到数据的共享问题,即当多个线程需要访问同一个资源时,它们需要以某种顺序来确保该资源在某一时刻只能被一个线程使用,否则,程序的运行结果将会是不可预料的,在这种情况下就必须对数据进行同步,例如多个线程同时对同一数据进行写操作,即当线程 A 需要使用某个资源时,如果这个资源正在被线程 B 使用,同步机制就会让线程 A 一直等待下去,直到线程 B 结束对该资源的使用后,线程 A 才能使用这个资源,由此可见,同步机制能够保证资源的安全。
要想实现同步操作,必须要获得每一个线程对象的锁。获得它可以保证在同一时刻只有一个线程能够进入临界区(访问互斥资源的代码块),并且在这个锁被释放之前,其他线程就不能再进入这个临界区。如果还有其他线程想要获得该对象的锁,只能进入等待队列等待。只有当拥有该对象锁的线程退出临界区时,锁才会被释放,等待队列中优先级最高的线程才能获得该锁,从而进入共享代码区。
Java 语言在同步机制中提供了语言级的支持,可以通过使用 synchronized 关键字来实现同步,但该方法并非「万金油」,它是以很大的系统开销作为代价的,有时候甚至可能造成死锁,所以,同步控制并非越多越好,要尽量避免无谓的同步控制。实现同步的方式有两种:一种是利用同步代码块来实现同步;另一种是利用同步方法来实现同步。
异步与非阻塞类似,由于每个线程都包含了运行时自身所需要的数据或方法,因此,在进行输入输出处理时,不必关心其他线程的状态或行为,也不必等到输入输出处理完毕才返回。当应用程序在对象上调用了一个需要花费很长时间来执行的方法,并且不希望让程序等待方法的返回时,就应该使用异步编程,异步能够提高程序的效率。
4.10.3 如何实现 Java 多线程
Java 虚拟机允许程序并发地运行多个线程。在 Java 语言中,多线程的实现一般有以下 3 种方法,其中前两种为最常用的方法。
1)继承 Thread 类,重写 run()方法
Thread 本质上也是实现了 Runnable 接口的一个实例,它代表一个线程的实例,并且,启动线程的唯一方法就是通过 Thread 类的 start()方法。start()方法是一个 native(本地)方法,它将启动一个新线程,并执行 run()方法(Thread 中提供的 run()方法是一个空方法)。这种方式通过自定义直接 extend Thread,并重写 run()方法,就可以启动新线程并执行自己定义的 run()方法。需要注意的是,调用 start()方法后并不是立即执行多线程代码,而是使得该线程变为可运行态(Runnable),什么时候运行多线程代码是由操作系统决定的。下例给出了 Thread 的使用方法:
2)实现 Runnable 接口,并实现该接口的 run()方法
以下是主要步骤:
1)自定义类并实现 Runnable 接口,实现 run()方法。
2)创建 Thread 对象,用实现 Runnable 接口的对象作为参数实例化该 Thread 对象。
3)调用 Thread 的 start()方法。
不管是通过继承 Thread 类还是通过使用 Runnable 接口来实现多线程的方法,最终还是通过 Thread 的对象的 API 来控制线程的。
(3)实现 Callable 接口,重写 call()方法
Callable 接口实际是属于 Executor 框架中的功能类,Callable 接口与 Runnable 接口的功能类似,但提供了比 Runnable 更强大的功能,主要表现为以下 3 点:
1)Callable 可以在任务结束后提供一个返回值,Runnable 无法提供这个功能。
2)Callable 中的 call()方法可以抛出异常,而 Runnable 的 run()方法不能抛出异常。
3)运行 Callable 可以拿到一个 Future 对象,Future 对象表示异步计算的结果,它提供了检查计算是否完成的方法。由于线程属于异步计算模型,因此无法从别的线程中得到函数的返回值,在这种情况下,就可以使用 Future 来监视目标线程调用 call()方法的情况,当调用 Fu-ture 的 get()方法以获取结果时,当前线程就会阻塞,直到 call()方法结束返回结果。
4.10.4 run()方法与 start()方法有什么区别
系统通过调用线程类的 start()方法来启动一个线程,此时该线程处于就绪状态,而非运行状态,也就意味着这个线程可以被 JVM 来调度执行。在调度过程中,JVM 通过调用线程类的 run()方法来完成实际的操作,当 run()方法结束后,此线程就会终止。
如果直接调用线程类的 run()方法,这会被当作一个普通的函数调用,程序中仍然只有主线程这一个线程,也就是说,start 方法()能够异步地调用 run()方法,但是直接调用 run()方法却是同步的,因此也就无法达到多线程的目的。
由此可知,只有通过调用线程类的 start()方法才能真正达到多线程的目的。下面通过一个例子来说明说明 run()方法与 start()方法的区别。
4.10.5 多线程同步的实现方法有哪些
当使用多线程访问同一个资源时,非常容易出现线程安全的问题(例如,当多个线程同时对一个数据进行修改时,会导致某些线程对数据的修改丢失)。因此,需要采用同步机制来解决这种问题。Java 主要提供了 3 种实现同步机制的方法:
(1)synchronized 关键字
在 Java 语言中,每个对象都有一个对象锁与之相关联,该锁表明对象在任何时候只允许被一个线程所拥有,当一个线程调用对象的一段 synchronized 代码时,需要先获取这个锁,然后去执行相应的代码,执行结束后,释放锁。
synchronized 关键字主要有两种用法(synchronized 方法和 synchronized 块),此外该关键字还可以作用于静态方法、类或某个实例,但这都对程序的效率有很大的影响。
1)synchronized 方法。在方法的声明前加入 synchronized 关键字,示例如下:
public synchronized void mutiThreadAccess();
只要把多个线程对类需要被同步的资源的操作放到 mutiThreadAccess()方法中,就能保证这个方法在同一时刻只能被一个线程访问,从而保证了多线程访问的安全性。然而,当一个方法的方法体规模非常大时,把该方法声明为 synchronized 会大大影响程序的执行效率。为了提高程序的效率,Java 提供了 synchronized 块。
2)synchronized 块。synchronized 块既可以把任意的代码段声明为 synchronized,也可以指定上锁的对象,有非常高的灵活性。其用法如下:
(2)wait()方法与 notify()方法
当使用 synchronized 来修饰某个共享资源时,如果线程 A1 在执行 synchronized 代码,另外一个线程 A2 也要同时执行同一对象的同一 synchronized 代码时,线程 A2 将要等到线程 A1 执行完成后,才能继续执行。在这种情况下可以使用 wait()方法和 notify()方法。
在 synchronized 代码被执行期间,线程可以调用对象的 wait()方法,释放对象锁,进入等待状态,并且可以调用 notify()方法或 notifyAll()方法通知正在等待的其他线程。notify()方法仅唤醒一个线程(等待队列中的第一个线程)并允许它去获得锁,notifyAll()方法唤醒所有等待这个对象的线程并允许它们去获得锁(并不是让所有唤醒线程都获取到锁,而是让它们去竞争)。
(3)Lock
JDK 5 新增加了 Lock 接口以及它的一个实现类 ReentrantLock(重入锁),Lock 也可以用来实现多线程的同步,具体而言,它提供了如下一些方法来实现多线程的同步:
1)lock()。以阻塞的方式获取锁,也就是说,如果获取到了锁,立即返回;如果别的线程持有锁,当前线程等待,直到获取锁后返回。
2)tryLock()。以非阻塞的方式获取锁。只是尝试性地去获取一下锁,如果获取到锁,立即返回 true,否则,立即返回 false。
3)tryLock(long timeout,TimeUnit unit)。如果获取了锁,立即返回 true,否则会等待参数给定的时间单元,在等待的过程中,如果获取了锁,就返回 true,如果等待超时,返回 false。
4)lockInterruptibly()。如果获取了锁,立即返回;如果没有获取锁,当前线程处于休眠状态,直到获得锁,或者当前线程被别的线程中断(会收到 InterruptedException 异常)。它与 lock()方法最大的区别在于如果 lock()方法获取不到锁,会一直处于阻塞状态,且会忽略 in-terrupt()方法,示例如下:
4.10.6 sleep()方法与 wait()方法有什么区别
具体而言,sleep()方法与 wait()方法的区别主要表现在以下几个方面:
1)原理不同。sleep()方法是 Thread 类的静态方法,是线程用来控制自身流程的,它会使此线程暂停执行一段时间,而把执行机会让给其他线程,等到计时时间一到,此线程会自动「苏醒」,例如,当线程执行报时功能时,每一秒钟打印出一个时间,那么此时就需要在打印方法前面加上一个 sleep()方法,以便让自己每隔 1s 执行一次,该过程如同闹钟一样。而 wait()方法是 Object 类的方法,用于线程间的通信,这个方法会使当前拥有该对象锁的进程等待,直到其他线程调用 notify()方法(或 notifyAll 方法)时才「醒」来,不过开发人员也可以给它指定一个时间,自动「醒」来。与 wait()方法配套的方法还有 notify()方法和 notifyAll()方法。
2)对锁的处理机制不同。由于 sleep()方法的主要作用是让线程暂停执行一段时间,时间一到则自动恢复,不涉及线程间的通信,因此,调用 sleep()方法并不会释放锁。而 wait()方法则不同,当调用 wait()方法后,线程会释放掉它所占用的锁,从而使线程所在对象中的其他 synchronized 数据可被别的线程使用。举个简单例子,如果何昊拿遥控器的期间,可以用自己的 sleep()方法每隔 10min 调一次频道,而在这 10min 里,遥控器还在他的手上。
3)使用区域不同。由于 wait()方法的特殊意义,因此它必须放在同步控制方法或者同步语句块中使用,而 sleep()方法则可以放在任何地方使用。
sleep()方法必须捕获异常,而 wait()、notify()以及 notifyall()不需要捕获异常。在 sleep 的过程中,有可能被其他对象调用它的 interrupt(),产生 InterruptedException 异常。
由于 sleep 不会释放「锁标志」,容易导致死锁问题的发生,因此,一般情况下,不推荐使用 sleep()方法,而推荐使用 wait()方法。
引申:sleep()方法与 yield()方法有什么区别?
sleep()方法与 yield()方法的区别主要表现在以下几个方面:
1)sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会,而 yield()方法只会给相同优先级或更高优先级的线程以运行的机会。
2)线程执行 sleep()方法后会转入阻塞状态,所以,执行 sleep()方法的线程在指定的时间内肯定不会被执行,而 yield()方法只是使当前线程重新回到可执行状态,所以执行 yield()方法的线程有可能在进入到可执行状态后马上又被执行。
3)sleep()方法声明抛出 InterruptedException,而 yield()方法没有声明任何异常。
4)sleep()方法比 yield()方法(跟操作系统相关)具有更好的可移植性。
常见笔试题:
1.利用 Thread.wait()同步线程,可以设置超时时间吗?
A.可以 B.不可以
答案:A。可以设置超时,函数原型为 wait(long timeout)和 wait(long timeout,int nanos)timeout 代表最长的等待时间,单位为 ms;nanos 代表额外的等待时间,单位为 ns。
2.在一个线程中 sleep(1000)方法,将使该线程在多长时间后获得对 CPU 的控制(假设睡眠过程中不会有其他事件唤醒该线程)?
A.正好 1000ms B.少于 1000ms C.大于等于 1000ms D.不一定
答案:C。sleep()方法指定的时间为线程不会运行的最短时间。当睡眠时间结束后,线程会返回到可运行状态,不是运行状态,还需要等待 CPU 调度执行。因此,sleep()方法不能保证该线程睡眠到期后就开始执行。
4.10.7 终止线程的方法有哪些
在 Java 语言中,可以使用 stop()方法与 suspend()方法来终止线程的执行。当用 Thread.stop()来终止线程时,它会释放已经锁定的所有监视资源。如果当前任何一个受这些监视资源保护的对象处于一个不一致的状态,其他线程将会「看」到这个不一致的状态,这可能会导致程序执行的不确定性,并且这种问题很难被定位。调用 suspend()方法容易发生死锁(死锁指的是两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,如果无外力作用,它们都将无法推进)。由于调用 suspend()方法不会释放锁,这就会导致一个问题:如果用一个 suspend 挂起一个有锁的线程,那么在锁恢复之前将不会被释放。如果调用 suspend()方法,线程将试图取得相同的锁,程序就会发生死锁,例如,线程 A 已经获取到了互斥资源 M 的锁,此时线程 A 通过 suspend()方法挂起线程 A 的执行,接着线程 B 也去访问互斥资源 M,这时候就造成了死锁。鉴于以上两种方法的不安全性,Java 语言已经不建议使用以上两种方法来终止线程了。
那么,如何才能终止线程呢?一般建议采用的方法是让线程自行结束进入 Dead 状态。一个线程进入 Dead 状态,即执行完 run()方法,也就是说,如果想要停止一个线程的执行,就要提供某种方式让线程能够自动结束 run()方法的执行。在实现时,可以通过设置一个 flag 标志来控制循环是否执行,通过这种方法来让线程离开 run()方法从而终止线程。下例给出了结束线程的方法:
上例中,通过调用 MyThread 的 stop()方法虽然能够终止线程,但同样也存在问题:当线程处于非运行状态时(当 sleep()方法被调用或当 wait()方法被调用或当被 I/O 阻塞时),上面介绍的方法就不可用了。此时可以使用 interrupt()方法来打破阻塞的情况,当 interrupt()方法被调用时,会抛出 InterruptedException 异常,可以通过在 run()方法中捕获这个异常来让线程安全退出,具体实现方式如下:
如果程序因为 I/O 而停滞,进入非运行状态,基本上要等到 I/O 完成才能离开这个状态,在这种情况下,无法使用 interrupt()方法来使程序离开 run()方法。这就需要使用一个替代的方法,基本思路也是触发一个异常,而这个异常与所使用的 I/O 相关,例如,如果使用 read-Line()方法在等待网络上的一个信息,此时线程处于阻塞状态。让程序离开 run()方法就是使用 close()方法来关闭流,在这种情况下会引发 IOException 异常,run()方法可以通过捕获这个异常来安全地结束线程。
4.10.8 synchronized 与 Lock 有什么异同
Java 语言提供了两种锁机制来实现对某个共享资源的同步:synchronized 和 Lock。其中,synchronized 使用 Object 对象本身的 notify、wait、notityAll 调度机制,而 Lock 可以使用 Condi-tion 进行线程之间的调度,完成 synchronized 实现的所有功能。
具体而言,二者的主要区别主要表现在以下几个方面的内容:
1)用法不一样。在需要同步的对象中加入 synchronized 控制,synchronized 既可以加在方法上,也可以加在特定代码块中,括号中表示需要锁的对象。而 Lock 需要显式地指定起始位置和终止位置。synchronized 是托管给 JVM 执行的,而 Lock 的锁定是通过代码实现的,它有比 synchronized 更精确的线程语义。
2)性能不一样。在 JDK 5 中增加了一个 Lock 接口的实现类 ReentrantLock。它不仅拥有和 synchronized 相同的并发性和内存语义,还多了锁投票、定时锁、等候和中断锁等。它们的性能在不同的情况下会有所不同:在资源竞争不是很激烈的情况下,synchronized 的性能要优于 ReetrantLock,但是在资源竞争很激烈的情况下,synchronized 的性能会下降得非常快,而 ReetrantLock 的性能基本保持不变。
3)锁机制不一样。synchronized 获得锁和释放的方式都是在块结构中,当获取多个锁时,必须以相反的顺序释放,并且是自动解锁,不会因为出了异常而导致锁没有被释放从而引发死锁。而 Lock 则需要开发人员手动去释放,并且必须在 finally 块中释放,否则会引起死锁问题的发生。此外,Lock 还提供了更强大的功能,它的 tryLock()方法可以采用非阻塞的方式去获取锁。
在实际使用时,最好不要同时使用两种上锁机制。
常考笔试题:
1.当一个线程进入一个对象的一个 synchronized()方法后,其他线程是否可进入此对象的其他方法?
答案:当一个线程进入一个对象的一个 synchronized()方法后,其他线程是否可进入此对象的其他方法取决于方法本身,如果该方法是非 synchronized()方法,那么是可以访问的,示例如下。从上例可以看出,线程 t1 在调用 sychronized()方法的过程中,线程 t2 仍然可以访问同一对象的非 sychronized()方法。
如果其他方法是静态方法(使用 static 修饰的方法),它用的同步锁是当前类的字节码,与非静态的方法不能同步(因为非静态的方法用的是 this),因此,静态方法可以被调用,示例如下。从上例可以看出,当线程 t1 在调用对象 t 的 sychronized()方法时,线程 t2 仍然可以调用这个对象的静态 sychronized()方法。
如果这个方法内部调用了 wait()方法,那么其他线程就可以访问同一对象的其他 sy-chronized()方法。如果这个方法内部没有调用 wait()方法,并且其他方法都为 sychronized()方法,那么其他线程将无法访问这个对象的其他方法。
4.10.9 什么是守护线程
Java 提供了两种线程:守护线程与用户线程。守护线程又被称为「服务进程」或「后台线程」,是指在程序运行时在后台提供一种通用服务的线程,这种线程并不属于程序中不可或缺的部分。通俗点讲,任何一个守护线程都是整个 JVM 中所有非守护线程的「保姆」。
用户线程和守护线程几乎一样,唯一的不同之处就在于如果用户线程已经全部退出运行,只剩下守护线程存在了,JVM 也就退出了。因为当所有非守护线程结束时,没有了被守护者,守护线程也就没有工作可做了,也就没有继续运行程序的必要了,程序也就终止了,同时会「杀死」所有守护线程。也就是说,只要有任何非守护线程还在运行,程序就不会终止。
在 Java 语言中,守护线程一般具有较低的优先级,它并非只由 JVM 内部提供,用户在编写程序时也可以自己设置守护线程,例如,将一个用户线程设置为守护线程的方法就是在调用 start()方法启动线程之前调用对象的 setDaemon(true)方法,若将以上参数设置为 false,则表示的是用户进程模式。需要注意的是,当在一个守护线程中产生了其他线程,那么这些新产生的线程默认还是守护线程,用户线程也是如此,示例如下:
从运行结果中可以发现,没有输出 Thread-0:end。之所以结果是这样,是在启动线程前将其设置为守护线程了,当程序中只有守护线程存在时,JVM 是可以退出的,也就是说,当 JVM 中只有守护线程运行时,JVM 会自动关闭。因此,当 test3 方法调用结束后,main 线程将退出,此时线程 t1 还处于休眠状态没有运行结束,但是由于此时只有这个守护线程在运行,JVM 将会关闭,因此不会输出「Thread-0:end」。
守护线程的一个典型的例子就是垃圾回收器。只要 JVM 启动,它始终在运行,实时监控和管理系统中可以被回收的资源。
常见笔试题:
1.Java 的 Daemon 线程,setDaemon 设置必须要( )。
A.在调用 start()方法之前 B.调用在 start()方法之后 C.前后都可以
答案:A。见上面讲解。
2.关于守护线程的说法,正确的是( )。
A.所有非守护线程终止,即使存在守护线程,进程运行终止
B.所有守护线程终止,即使存在非守护线程,进程运行终止
C.只要有守护线程或者非守护进程其中之一存在,进程就不会终止
D.只要所有守护线程和非守护线程终止运行之后,进程才会终止
答案:A。见上面讲解。
4.10.10 join()方法的作用是什么
在 Java 语言中,join()方法的作用是让调用该方法的线程在执行完 run()方法后,再执行 join 方法后面的代码。简单点说,就是将两个线程合并,用于实现同步功能。具体而言,可以通过线程 A 的 join()方法来等待线程 A 的结束,或者使用线程 A 的 join(2000)方法来等待线程 A 的结束,但最多只等待 2s,示例如下:
===========================================
end
来源:CSDN
作者:康冕峰
链接:https://blog.csdn.net/qq_40993412/article/details/104060629