java并发编程的艺术——第四章总结

时间:2024-03-29 17:34:56

第四章并发编程基础

  java语言是内置对多线程支持的。

  为什么使用多线程:

    首先线程是操作系统最小的调度单元,多核心、多个线程可以同时执行,能够提升程序性能。但是过多的线程也会导致问题(效率下降,死锁)。


4.1线程简介

4.1.1什么是线程

  进程:现代操作系统运行一个程序时,会为其创建一个进程。(进程可以认为是运行着的程序,活着的程序)。

  线程:现代操作系统的最新调度单位就是线程,线程也称为轻量级进程(Light Weight Process)。

    一个进程中可以包含多个线程,这些线程有自己的计数器、堆栈、和局部变量属性,并且能够访问共享的内存变量。

  为什么线程能够同时执行:处理器在不同的线程间高速切换,让使用者感觉到线程在同时运行。

  java程序天生就是一个多线程,下面展示一个简单的java程序包含哪些最基本的线程:

public class Main {
public static void main(String[] args) throws Exception{
//获取java线程管理MXBean
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
//不需要获取同步的Monitor和synchronizer信息,仅获取线程和线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false,false);
//遍历线程信息,仅打印线程Id和线程名称
for (ThreadInfo ti : threadInfos) {
System.out.println("[" + ti.getThreadId() + "] " + ti.getThreadName());
}
}
}

结果:

java并发编程的艺术——第四章总结

Signal Dispatcher  分发处理发送给JVM信号的线程。

Finalizer      调用对象的finalize方法的线程

Reference Handler  清除Reference的线程

main        主线程

可以看到一个普通的java程序不仅仅只有主线程,而且还有其他多个线程在运行。


4.1.2为什么要使用多线程

  1)更多处理器核心:

    一个线程在同一时刻只能运行在一个处理器上,将逻辑分配到多个核心处理器上更加有效率。

  2)更快的响应时间:

    将数据一致性不强的操作分派给其他线程,使响应用户请求的线程尽快完成,缩短响应时间。

  3)更好的编程模型:

    考虑问题时,仅需将业务建立起合适的模型,而无须考虑复杂的底层实现。

    (这个底层实现在第二章第三章都有描述,实际上在编程语言与操作系统的线程之间是有复杂的映射的,这里JVM帮我们处理好了,我们只需要根据业务建立多线程模型编程即可)


4.1.3线程的优先级

  现代操作系统采用时分的形式调度运行的线程。

  操作系统分出一个个时间片,线程会分配到若干时间片,时间片使用完就会发生线程调度,等待下次分配。线程分配的时间片多少也决定了线程使用处理器资源的多少。

  线程优先级就是决定线程需要多或者少分配一些处理器资源的线程属性。

  在java线程中,有一个整型成员变量priority来控制优先级,范围从1~10,超出就会报出异常。

  构建线程时,默认优先级是5。

  优先级高则分配到的时间片数量多于低的。

  策略:针对频繁阻塞(休眠/IO操作)的线程需要设置较高的优先级,针对计算的(占用较多CPU时间或者偏向运算)的设置较低的优先级,这样会避免线程被独占。

  

注:线程的优先级仅仅是一部分决定因素,因为线程的切换具有随机性,而且针对不同的系统而言,优先级这个概念可能就不存在,他仅仅是决定程序设计的衡量的一个标准。


4.1.4线程的状态

  java并发编程的艺术——第四章总结

  事实上,线程在整个运行过程中是会随着代码的运行而不断变化状态的。

java并发编程的艺术——第四章总结

java并发编程的艺术——第四章总结

  注:java将操作系统中的运行和就绪两个状态合并称为运行状态,阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(竞争锁)时的状态。

    但是阻塞在java.concurrent包中Lock接口的线程状态却是等待状态,因为Lock接口对于阻塞的实现使用了LockSupport类中相关的方法。


4.1.5Daemon线程

  Daemon线程是一种支持性线程,主要是用在后台程序做一些后台调度与支持性工作。这意味着当JVM中没有非Daemon线程时,JVM将自动退出。

  可以通过调用Thread.setDaemon(true)方法将线程设为Daemon线程。(注:该方法必须在start()或者run()方法前执行,也就是说必须在线程启动前执行)

  Daemon线程被用作,完成支持性工作,但是在java虚拟机退出时,Daemon线程中的finally块并不一定会执行。

  注:在构建Daemon时,不能依靠finally块中的内容来确保执行关闭或清理资源的逻辑。


4.2启动与终止

4.2.1构造线程

  线程运行前需要先构造一个线程,下面是java.lang.Thread中对线程进行初始化的部分

  java并发编程的艺术——第四章总结

  从这里也能看到父线程与子线程的关系:

  父线程就是当前线程(开启多线程的线程),子线程会具有与父线程一致的优先级, 守护线程,线程组,还会有父线程的可继承ThreadLocal。还会分配给一个唯一的ID。

  init()运行完毕,线程对象就初始化好了,在堆内存中等待运行


4.2.2启动线程

  线程完成初始化后,调用start()方法就可以启动这个线程,

  线程start()的含义:当前线程同步告知JVM,只要线程规划器空闲,应立即启动调用start()方法的线程。

  注:作为一个习惯,最好为自定义线程起一个好名字。根据构造方法,为自定义线程取个好名字吧。

java并发编程的艺术——第四章总结


4.2.3理解中断

  中断:一个标识位属性,通过调用线程的interrupt()方法使其进入中断状态。

  线程可以通过检查自身是否被中断来进行响应。

  线程通过方法isInterrupted()来判断是否被中断,也可以调用静态方法Thread.interrupted()对当前线程的中断进行复位。

  注:线程已经结束,即使线程曾经处于中断状态,调用线程对象的isInterrupted()依旧会返回false。

    只要线程进入打断状态(调用interrupt()方法),在调用sleep(),会抛出异常InterruptedException。同时JVM会将线程的打断状态清空,此时再调用isInterrupted()会返回false。


4.2.4过期的suspend()、resume()和stop()方法

  suspend()用于暂停线程、resume()用于恢复线程、stop()用于停止线程,这三个方法都过期了。

  原因:suspend()会导致线程占用资源进入休眠状态,容易导致死锁。stop()不能保证线程资源的正确释放,一旦调用直接结束,可能会导致程序运行在不确定的状态。

  暂停恢复方法可以用后面的等待/通知机制完成。


4.2.5安全地终止线程

  前面4.2.3提到中断状态是一个线程的标识位。可以利用一个boolean变量来控制是否需要停止任务并终止该线程。

public class InterruptTest {
public static void main(String[] args) throws Exception{
Runner one = new Runner();
Thread countThread = new Thread(one,"CountThread");
countThread.start();
//睡眠一秒,main线程对CountThread进行中断,使CountThread能够感知并结束。
Thread.sleep(1000);
countThread.interrupt();
Runner two = new Runner();
countThread = new Thread(two,"CountThread");
countThread.start();
//睡眠一秒,main线程对two进行cancel,使得CountThread能够感知并结束
Thread.sleep(1000);
two.cancel();
} private static class Runner implements Runnable {
private long i;
private volatile boolean on = true; @Override
public void run() {
while (on && !Thread.currentThread().isInterrupted()) {
i++;
}
System.out.println("Count i = " + i);
} public void cancel() {
on = false;
}
}
}

结果:

java并发编程的艺术——第四章总结

 在java多线程编程核心技术中也提到过,使用判断结合interrupt()与isInterrupted()的方式来终止多线程的执行但是当时并没有补充示例,如今已经补充了示例。


4.3线程间的通信

  线程自启动时,就拥有了自己的栈空间。然后会一直运行直到结束。

  多线程的目的是多条线程执行不同的逻辑业务从而能够提升业务整体的响应速度,如果线程仅仅是孤零零的执行,这些不同的逻辑业务就不能最终汇聚成一个完整的业务那么多线程也就失去了意义,这就是为什么要有线程间通信的存在。


4.3.1volatile与synchronized关键字

  java支持多个线程访问一个对象或对象的成员变量,在不使用关键字时,每一个线程是从自己的内存区域获取相应对象的拷贝的。

  注:线程有自己的内存区域,默认会将共享内存中的数据拷贝到自己的内存区域,然后对拷贝值进行操作。这样可以加速程序的执行,这也是现代多核处理器的一个特点。

    但是这也会引出一个问题,即每一个线程获得的变量并不一定是最新值。

  关键字volatile可以修饰字段(成员变量),就是告知程序,任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。

    注:过多地使用volatile是不必要的,因为它会降低程序执行的效率。

      (前面已经提到每一个线程都有自己的内存区域,从自己的内存区域对值操作肯定最快的,使用了volatile的话就会对共享内存进行操作,相比之下自然速率就慢了)

  

  关键字synchronized:修饰代码块、方法、静态方法。

    实质上是对一个对象的监视器(monitor)的获取,而且这个获取过程是排他的,也就是说同一时刻只有一个线程获取由synchronized所保护对象的监视器。

  任何对象都有自己的监视器,当对象由同步块或者对象的同步方法调用时,执行方法的线程必须先获取对象的监视器才能进入同步块或者同步方法,而没有获取监视器的线程会阻塞在同步块与同步方法的入口,进入BLOCKED状态。

java并发编程的艺术——第四章总结


4.3.2等待/通知机制

  等待/通知的相关方法是任意java对象都具备的,因为该方法被定义在所有对象的超类上java.lang.Object

  java并发编程的艺术——第四章总结

  等待通知机制:线程A调用了对象O的wait()方法进入了等待状态,而线程B调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作。

  注:上述两个线程通过对象O来完成交互,而对象的wait()与notify()或notifyAll()的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。

  使用注意:

    1)使用wait()、notify()、notifyAll()方法都需要先对调用对象加锁。(即锁对象应该为调用对象)

    2)调用wait()方法后,线程状态由RUNNING变为WAITTING,将锁释放,并将当前线程放到对象的等待队列。

    3)notify()或notifyAll()方法调用后,不会立刻释放锁,需要等待调用notify()、notifyAll()的线程释放锁之后,等待线程才可能会拿到锁。

    4)notify()将对象的等待队列中的一个线程随机地移到同步对象,notifyAll()将等待队列中的全部线程都移到同步队列,然后使它们争抢锁,被移动的状态由WAITING变为BLOCKED。

    5)从wait()返回的前提是获取调用对象的锁。


4.3.3等待/通知的经典范式

  经典范式可以分为两部分  等待方(消费者)与通知方(生产者)。

  等待方遵循原则:

          1)获取对象的锁

          2)如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件。

          3)条件满足执行对应的逻辑

  java并发编程的艺术——第四章总结

  通知方遵循原则:

          1)获得对象锁

          2)改变条件

          3)通知所有等待在对象上的线程

  java并发编程的艺术——第四章总结


4.3.4管道输入/输出流

  管道输入/输出流用于线程间的数据传输,传输的媒介是内存。

  PipedOutputStream/PipedIntputStream

  PipedReader/PipedWriter

  注:在使用管道流的时候要注意,一定要进行绑定,也就是调用connect()方法,否则会出异常。

public class StreamTest {
public static void main(String[] args) throws Exception{
PipedWriter out = new PipedWriter();
PipedReader in = new PipedReader();
//将输入输出流进行连接,否则会爆出异常IOException
out.connect(in);
Thread printThread = new Thread(new Print(in),"PrintThread");
printThread.start();
int receive = 0;
while ((receive = System.in.read()) != -1) {
out.write(receive);
}
out.close();
} static class Print implements Runnable {
private PipedReader in;
public Print(PipedReader in) {
this.in = in;
} @Override
public void run() {
int receive = 0;
try {
while ((receive = in.read()) != -1) {
System.out.println((char)receive);
}
}catch (IOException e) {
e.printStackTrace();
}
}
}
}

4.3.5Thread.join()的使用

  如果一个线程A执行了thread.join(),含义:当前线程A等待thread线程终止后才从thread.join()返回。

  除了join()外还有join(long millis)和join(long millis,int nanos)两个具备超时的方法,这两个方法表示:如果线程thread没有在指定时间内停止,那么线程A会从该超时方法返回。

  下面是Thread.join()的部分源码:

  java并发编程的艺术——第四章总结


4.3.6ThreadLocal的使用

  ThreadLocal,即线程变量,是一个以ThreadLocal对象为键,任意对象为值的存储结构。

  这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。

  可以通过set(T)来设置值,然后在当前线程下使用get()来获取原先设置的值。

  不过有点遗憾的是只能放一个值。对你没有看错只能放一个值,再次调用set设置值,会覆盖前一次set的值。


4.4线程应用实例  


4.4.1等待超时模式

   应用场景:调用一个方法时等待一段时间,如果该方法能够在给定时间段内得到结果,那么将结果返回,否则返回默认值。

    假设超时时间是T,那么可以推断出在当前时间为now+T时就为超时。

  定义变量如下:

    等待持续时间:REMAINING = T;

    超时时间:  FUTURE = now + T;

  这时仅需要wait(REMAINING)即可,在wait(REMAINING)返回之后会将执行:REMAINING = FUTURE - now。如果REMAINING = 0,表示超时,直接退出,否则将继续执行wait(REMAINING)。

java并发编程的艺术——第四章总结

  这样就可以避免执行时间过长,也不会“永久”阻塞调用者,而是按照调用者的要求返回。


后面的示例过于冗长,而且不太好总结。反而更建议去阅读书籍。

看到这一章终于算是接触到了多线程方面的知识,比起前几章底层的东西,突然觉得线程部分还是相对简单一些?

这一章主要讲了一些线程的使用基础的部分。

比如volatile,synchronized,优先度啊,线程启动中断啊,线程通信啊等等。

这本书跟java多线程编程核心技术相比,

知识点更琐碎也更凝练。

琐碎表现在:解释一个现象时可能会介绍到关于一些底层的原理而不是直接告诉你表面看到的现象,也会很详细的告诉你一些名词及知识的含义。

凝练表现在:没有过于多的基础的代码,一些伪代码使用起来反而显得很直观,java多线程编程核心技术有时候会为了讲一个知识点写几页的代码。

java多线程编程核心技术相对来说更适合入门,而这本书在有了一定的线程基础后看起来也更加快,在帮你拓展理解一些更底层的东西时,也会更加轻松一些。

本文内容是书中内容兼具自己的个人看法所成。可能在个人看法上会有诸多问题(毕竟知识量有限,导致认知也有限),如果读者觉得有问题请大胆提出,我们可以相互交流、相互学习,欢迎你们的到来,心成意足,等待您的评价。