初识多线程及其原理-笔记

什么情况下应该使用多线程?

  • 通过并行计算提高程序执行性能
  • 需要等待网络、I/O响应导致耗费大量的执行时间,
    • 可以采用异步线程的方式来减少阻塞

tomcat7 以前的io模型

  • 客户端阻塞
  • 线程级别阻塞 BIO

如何应用多线程?

  • 在Java中,有多种方式来实现多线程。
  • 继承Thread类、实现Runnable接口、
  • 使用ExecutorService、Callable、Future实现带返回结果的多线程。

继承Thread类创建线程

  • Thread类本质上是实现了Runnable接口的一个实例,代表一个线程的实例。
  • Thread类本质上是实现了Runnable接口的一个实例,代表一个线程的实例。
  • 启动线程的唯一方法就是通过Thread类的start()实例方法。
  • start()方法是一个native方法,它会启动一个新线程,并执行run()方法。

实现Runnable接口创建线程

  • 如果自己的类已经extends另一个类,就无法直接extends Thread,
  • 此时,可以实现一个Runnable接口

实现Callable接口通过FutureTask包装器来创建Thread线程

  • 有的时候,我们可能需要让一步执行的线程在执行完成以后,
  • 提供一个返回值给到当前的主线程,主线程需要依赖这个值进行后续的逻辑处理,
  • 那么这个时候,就需要用到带返回值的线程了。
public class CallableDemo implements Callable<String> {

	@Override
	public String call() throws Exception {
		int a = 1;
		int b = 2;
		System.out.println(a + b);
		return "执行结果:" + (a + b);
	}

	public static void main(String[] args) throws ExecutionException, InterruptedException {
		ExecutorService executorService = Executors.newFixedThreadPool(1);
		CallableDemo callableDemo = new CallableDemo();
		Future<String> future = executorService.submit(callableDemo);
		System.out.println(future.get());
		executorService.shutdown();
	}
}

Java 并发编程基础

线程的状态

  • 线程一共有6种状态(
    • NEW、
    • RUNNABLE、
    • BLOCKED、
    • WAITING、
    • TIME_WAITING、
    • TERMINATED)
  • NEW:初始状态,线程被构建,但是还没有调用start方法
  • RUNNABLED:运行状态,JAVA线程把操作系统中的就绪和运行两种状态统一称为“运行中”
  • BLOCKED:阻塞状态,表示线程进入等待状态,也就是线程因为某种原因放弃了CPU使用权,阻塞也分为几种情况
    • 等待阻塞:运行的线程执行wait方法,jvm会把当前线程放入到等待队列
    • 同步阻塞:运行的线程在获取对象的同步锁时,
      • 若该同步锁被其他线程锁占用了,那么jvm会把当前的线程放入到锁池中
    • 其他阻塞:运行的线程执行Thread.sleep或者t.join方法,
      • 或者发出了I/O请求时,JVM会把当前线程设置为阻塞状态,
      • 当sleep结束、join线程终止、io处理完毕则线程恢复
  • TIME_WAITING:超时等待状态,超时以后自动返回
  • TERMINATED:终止状态,表示当前线程执行完毕

通过相应命令显示线程状态

  • 打开终端或者命令提示符,键入“jps”,可以获得相应进程的pid
  • 根据上一步骤获得的pid,继续输入jstack pid
    • jstack是java虚拟机自带的一种堆栈跟踪工具。
    • jstack用于打印出给定的java进程ID或core file或远程调试服务的Java堆栈信息

线程的停止

  • stop、suspend、resume过期不建议使用
    • stop方法在结束一个线程时并不会保证线程的资源正常释放,
    • 因此会导致程序可能出现一些不确定的状态。

要优雅的去中断一个线程,在线程中提供了一个interrupt方法

  • 当其他线程通过调用当前线程的interrupt方法,
  • 表示向当前线程打个招呼,告诉他可以中断线程的执行了,至于什么时候中断,取决于当前线程自己。
  • 可以通过isInterrupted()来判断是否被中断
  • 这种通过标识位或者中断操作的方式能够使线程在终止时有机会去清理资源,而不是武断地将线程停止,
    • 因此这种终止线程的做法显得更加安全和优雅

Thread.interrupted

  • 注意区别,这个是复位方法,与interrupt中断方法对应
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (true) {
                boolean ii = Thread.currentThread().isInterrupted();
                if (ii) {
                    System.out.println("before:" + ii);
                    Thread.interrupted();//对线程进行复位,中断标识为false 
                    System.out.println("after:" + Thread.currentThread().isInterrupted());
                }
            }
        });
        thread.start();
        TimeUnit.SECONDS.sleep(1);
        thread.interrupt();//设置中断标识,中断标识为true 
    }
}

其他的线程复位

  • 还有一种被动复位的场景,就是对抛出InterruptedException异常的方法,
  • 在InterruptedException抛出之前,JVM会先把线程的中断标识位清除,
  • 然后才会抛出InterruptedException,这个时候如果调用isInterrupted方法,将会返回false
public class InterruptDemo {
   public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    //抛出该异常,会将复位标识设置为false 
                    e.printStackTrace();
                }
            }
        });
        thread.start();
        TimeUnit.SECONDS.sleep(1);
        thread.interrupt();
        //设置复位标识为true 
        TimeUnit.SECONDS.sleep(1);
        System.out.println(thread.isInterrupted());//false
    }
}

首先我们来看看线程执行interrupt以后的源码是做了什么?

  • 其实就是通过unpark去唤醒当前线程,并且设置一个标识位为true。
  • 并没有所谓的中断线程的操作,所以实际上,线程复位可以用来实现多个线程之间的通信。

线程的停止方法之2

  • 定义一个volatile修饰的成员变量,来控制线程的终止
  • 这实际上是应用了volatile能够实现多线程之间共享变量的可见性这一特点来实现的。
    public class VolatileDemo {
        private volatile static boolean stop = false;

        public static void main(String[] args) throws InterruptedException {
            Thread thread = new Thread(() -> {
                int i = 0;
                while (!stop) {
                    i++;
                }
            });
            thread.start();
            System.out.println("begin start thread");
            Thread.sleep(1000);
            stop = true;
        }
    }

线程的安全性问题

  • 我们从原理层面去了解线程为什么会存在安全性问题,并且我们应该怎么去解决这类的问题。
  • 线程安全问题可以总结为: 可见性原子性有序性这几个问题,
  • 我们搞懂了这几个问题并且知道怎么解决,那么多线程安全性问题也就不是问题了

CPU高速缓存

  • 线程是CPU调度的最小单元,线程涉及的目的最终仍然是更充分的利用计算机处理的效能,
  • 但是绝大部分的运算任务不能只依靠处理器“计算”就能完成,
    • 处理器还需要与内存交互,比如读取运算数据、存储运算结果,这个I/O操作是很难消除的。
  • 而由于计算机的存储设备与处理器的运算速度差距非常大,
    • 所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:
    • 将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。

  • 高速缓存从下到上越接近CPU速度越快,同时容量也越小。
  • 现在大部分的处理器都有二级或者三级缓存,从下到上依次为 L3 cache, L2 cache, L1 cache.
  • 缓存又可以分为指令缓存和数据缓存,指令缓存用来缓存程序的代码数据缓存用来缓存程序的数据

L1 Cache,

  • 一级缓存,本地core(cpu核心)的缓存,
  • 分成32K的数据缓存L1d和32k指令缓存L1i,
  • 访问L1需要3cycles,耗时大约1ns;

L2 Cache,

  • 二级缓存,本地core(cpu核心)的缓存,
  • 被设计为L1缓存与共享的L3缓存之间的缓冲,大小为256K,
  • 访问L2需要12cycles,耗时大约3ns;

L3 Cache,

  • 三级缓存,在同插槽的所有core(cpu核心)共享L3缓存,分为多个2M的段,
  • 访问L3需要38cycles,耗时大约12ns;

缓存一致性问题

  • CPU-0读取主存的数据,缓存到CPU-0的高速缓存中,
  • CPU-1也做了同样的事情,而CPU-1把count的值修改成了2,并且同步到CPU-1的高速缓存,
  • 但是这个修改以后的值并没有写入到主存中,
  • CPU-0访问该字节,由于缓存没有更新,所以仍然是之前的值,就会导致数据不一致的问题

引发这个问题的原因是

  • 因为多核心CPU情况下存在指令并行执行,
  • 而各个CPU核心之间的数据不共享从而导致缓存一致性问题,
  • 为了解决这个问题,CPU生产厂商提供了相应的解决方案

总线锁

  • 当一个CPU对其缓存中的数据进行操作的时候,往总线中发送一个Lock信号。
  • 其他处理器的请求将会被阻塞,那么该处理器可以独占共享内存。
  • 总线锁相当于把CPU和内存之间的通信锁住了
    • 所以这种方式会导致CPU的性能下降,
    • 所以P6系列以后的处理器,出现了另外一种方式,就是缓存锁。

缓存锁

  • 如果缓存在处理器缓存行中的内存区域在LOCK操作期间被锁定,
  • 当它执行锁操作回写内存时,处理不在总线上声明LOCK信号,而是修改内部的缓存地址
  • 然后通过缓存一致性机制来保证操作的原子性,
    • 因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域的数据,
    • 当其他处理器回写已经被锁定的缓存行的数据时会导致该缓存行无效。

所以如果声明了CPU的锁机制,会生成一个LOCK指令,会产生两个作用:

  • Lock前缀指令会引起处理器缓存回写到内存,在P6以后的处理器中,LOCK信号一般不锁总线,而是锁缓存
  •  一个处理器的缓存回写到内存会导致其他处理器的缓存无效

缓存一致性协议

  • 处理器上有一套完整的协议,来保证Cache的一致性,比较经典的应该就是:MESI
  • MESI协议的方法是在CPU缓存中保存一个标记位,
    • 这个标记为有四种状态:
      • M(Modified) 修改缓存,当前CPU缓存已经被修改,表示已经和内存中的数据不一致了
      • I(Invalid) 失效缓存,说明CPU的缓存已经不能使用了
      • E(Exclusive) 独占缓存,当前cpu的缓存和内存中数据保持一直,而且其他处理器没有缓存该数据
      • S(Shared) 共享缓存,数据和内存中数据一致,并且该数据存在多个cpu缓存中
  • 每个Core(cpu核心)的Cache控制器不仅知道自己的读写操作,也监听其它Cache的读写操作,嗅探(snooping)协议

CPU的读取会遵循几个原则:

  • 如果缓存的状态是I,那么就从内存中读取,否则直接从缓存读取
  • 如果缓存处于M或者E的CPU 嗅探到其他CPU有读的操作,就把自己的缓存写入到内存,并把自己的状态设置为S
  • 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M

CPU的优化执行

  • 除了增加高速缓存以外,
  • 为了更充分利用处理器内内部的运算单元,处理器可能会对输入的代码进行乱序执行优化,
  • 处理器会在计算之后将乱序执行的结果充足,保证该结果与顺序执行的结果一致,
  • 但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,这个是处理器的优化执行
  • 还有一个就是编程语言的编译器也会有类似的优化,比如做指令重排来提升性能

并发编程的问题

  • 前面说的和硬件有关的概念你可能听得有点蒙,还不知道他到底和软件有啥关系
    • 其实原子性、可见性、有序性问题,是我们抽象出来的概念,
    • 他们的核心本质就是刚刚提到的缓存一致性问题处理器优化问题  导致指令重排序问题
  • 比如缓存一致性就导致可见性问题、
  • 处理器的乱序执行会导致原子性问题、
  • 指令重排会导致有序性问题。
  • 为了解决这些问题,所以在JVM中引入了JMM的概念

内存模型(JMM

  • 内存模型定义了共享内存系统中多线程程序读写操作行为的规范,
    • 来屏蔽各种硬件和操作系统的内存访问差异,
    • 来实现Java程序在各个平台下都能达到一致的内存访问效果。
  • Java内存模型的主要目标是定义程序中各个变量的访问规则,
    • 也就是在虚拟机中将变量存储到内存以及从内存中取出变量
    • 这里的变量,指的是共享变量,也就是实例对象、静态字段、数组对象等存储在堆内存中的变量
    • 而对于局部变量这类的,属于线程私有,不会被共享
  • 通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。
    • 它与处理器有关、与缓存有关、与并发有关、与编译器也有关。
    • 他解决了CP;多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的可见性、原子性和有序性。
    • 内存模型解决并发问题主要采用两种方式:限制处理器优化使用内存屏障

Java内存模型定义了线程和内存的交互方式,

  • 在JMM抽象模型中,分为主内存、工作内存。
  • 主内存是所有线程共享的,工作内存是每个线程独有的。
  • 线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存中的变量。
  • 并且不同的线程之间无法访问对方工作内存中的变量,
  • 线程间的变量值的传递都需要通过主内存来完成,他们三者的交互关系如下:

所以,总的来说,JMM是一种规范,

  • 目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
  • 目的是保证并发编程场景中的原子性、可见性和有序性

猜你喜欢

转载自my.oschina.net/u/3847203/blog/2986978