Java 并发基础总结

说实话,这么长的时间以来,很多的并发编程的知识都是零零散散的积累和学习到的,可能是通过许多博客,或者是平时学习工作中代码出现过的问题,也可能是听老师和同学说起过(其实我的大学的老师在这方面都比较菜,毕竟他们的主要研究方向不在这,也不做业务)。所以很多时候,对知识的理解力、思维横向性、总体把握能力都略感不足。因此需要一个系统的总结,将知识首尾贯穿,方能有心体通透、彻悟感叹。

学习忌浮躁

(附思维导图)
在这里插入图片描述

线程状态

6 个状态定义:java.lang.Thread.State
  1. New:尚未启动的线程的线程状态
  2. Runnable:可运行的线程状态,等待 CPU 调度。
  3. Blocked:线程阻塞等待监视器锁定的线程状态。
  4. Waiting:等待线程的线程状态。下列不带超时的方式:
    Object.wait、Thread.join、LockSupport.park
  5. Timed Waiting:具有指定等待时间的等待线程的线程状态。下列带超时的方式:
    Thread.sleep、Object.wait、Thread.join、LockSupport.parkNanos、LockSupport.parkUntil
  6. Terminated:线程终止的状态。线程正常完成执行或者出现异常。

可以说是 5 种状态,也可以说是 6 种,毕竟 4、5 本质上是没有多大区别的。
下面是这些状态之间的关系:
在这里插入图片描述

线程中止

不正确的线程终止-stop:

终止线程,并且清除监控器锁的信息,但是可能线程安全问题。所以现已被弃用。
Destroy:JDK 未实现该方法。
要让一个线程结束,最好的方法是让它执行完成自己所有的方法。对于不间断重复运行的线程,我们一般都是设置一个标志位,通过更改标志位的方式让线程结束。

通过状态位来判断
/** 通过状态位来判断 */
public class Demo4 extends Thread {
  public volatile static boolean flag = true;
  /** main方法入口 */
  public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
      try {
        while (flag) { // 判断是否运行
          System.out.println("运行中");
          Thread.sleep(1000L);
        }
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }).start();
    // 3秒之后,将状态标志改为False,代表不继续运行
    Thread.sleep(3000L);
    flag = false;
    System.out.println("程序运行结束");
  }
}

线程封闭

ThreadLocal

多线程访问共享可变数据时,涉及到线程间数据同步的问题。并不是所有时候,都要用到共享数据,所以线程封闭概念就提出来了。
数据都被封闭在各自的线程之中,就不需要同步,这种通过将数据封闭在线程中而避免使用同步的技术成为 线程封闭
线程封闭的具体体现有:ThreadLocal、局部变量

ThreadLocal 是 Java 里一种特殊的变量。
它是一个线程级别变量,每个线程都有一个 ThreadLocal 变量副本,竞争条件被彻底消除了,在并发模式下是绝对安全的变量。

线程封闭示例
/** 线程封闭示例 */
public class Demo5 {
	/** threadLocal变量,每个线程都有一个副本,互不干扰 */
	public static ThreadLocal<String> value = new ThreadLocal<>();

	public void threadLocalTest() throws Exception {
		// threadlocal线程封闭示例
		value.set("这是主线程设置的123"); // 主线程设置值
		String v = value.get();
		System.out.println("线程1执行之前,主线程取到的值:" + v);

		new Thread(new Runnable() {
			@Override
			public void run() {
				String v = value.get();
				System.out.println("线程1取到的值:" + v);
				// 设置 threadLocal
				value.set("这是线程1设置的456");

				v = value.get();
				System.out.println("重新设置之后,线程1取到的值:" + v);
				System.out.println("线程1执行结束");
			}
		}).start();

		Thread.sleep(5000L); // 等待所有线程执行结束

		v = value.get();
		System.out.println("线程1执行之后,主线程取到的值:" + v);

	}
	public static void main(String[] args) throws Exception {
		new Demo5().threadLocalTest();
	}
}

通过示例我们可以很清晰的看到每个线程中的 ThreadLocal 的值互相不受影响。
最常见的是用 ThreadLocal 来存储数据库连接,来保证安全。

线程池

线程是不是越多越好?

  • 线程在 java 中是一个对象,更是操作系统的资源,线程创建、销毁需要时间。如果创建时间+销毁时间 > 执行任务时间,就会很不划算。
  • java 对象占用堆内存,操作系统线程占用系统内存,根据 jvm 规范,一个线程默认最大栈大小 1M,这个栈空间是需要从系统内存中分配的。线程过多,会消耗很多的内存。
  • 操作系统需要频繁切换线程上下文(大家都想被运行),影响性能。
线程池原理
  • 线程池管理器:用于创建并管理线程池,包括创建线程池,销毁线程池,添加新任务;
  • 工作线程:线程池中线程,在没有任务时处于等待状态,可以循环的执行任务;
  • 任务接口:每个任务必须实现的接口,以供工作线程调度任务的执行,它主要规定了任务的入口,任务执行完后的收尾工作,任务的执行状态等;
  • 任务队列:用于存放没有处理的任务。提供一种缓冲机制。

线程池里的工作线程就像是货车司机,没有任务时就在等待获取任务,来了任务之后就会运着我们的任务去 CPU 上执行。但是货车司机总是有限的,如果大量的任务堆过来,我们的货车不够,那么这些任务就会排着队被放到仓库里面去,等到司机从 CPU 返回时,在从仓库里取出排在前面的任务,继续执行。
在这里插入图片描述

线程池任务的提交过程
  • 是否达到核心线程数量?没达到,创建一个工作线程来执行。
  • 工作队列是否已满?没满,则将新提交的任务存储在工作队列里。
  • 是否达到线程池最大数量?没达到,则创建一个新的工作线程来执行任务。
  • 最后,执行拒绝策略来处理这个任务。

在这里插入图片描述

内存屏障和CPU缓存

为了提高程序运行的性能,现代 CPU 在很多方面对程序进行了优化。
例如:CPU 高速缓存。尽可能避免处理器访问主内存的时间开销,处理器大多会用缓存(Cache)以提高性能。

  • L1 Cache(一级缓存)是 CPU 第一层高速缓存,分为数据缓存和指令缓存。一般服务器 CPU 的 L1 缓存通常在 32-4096KB。
  • L2 由于 L1 级高速缓存容量的限制,为了再一次提高 CPU 运算速度,在 CPU 外部放置一高速存储器,及二级缓存。
  • L3 现在的都是内置的。它的实际作用即是,L3 缓存的应用可以进一步降低内存延迟,同时提升大数据量计算时处理器的性能。具有较大 L3 缓存的处理器提供更有效的文件系统缓存行为及较短消息和处理器队列长度。一般是多核共享 L3 缓存!

CPU 在读取数据时,先从 L1 寻找,再从 L2 寻找,再从 L3 寻找,然后是内存,再后是外存储器。
CPU的三级缓存

缓存一致性协议

多 CPU 读取同样的数据进行缓存,进行不同运算之后,最终写入主内存以哪个 CPU 为准?
在这种高速缓存回写的场景下,有一个缓存一致性协议多数 CPU 厂商对它进行了实现。
MESI协议,它规定每条缓存有个状态位,同时定义了下面四个状态:

  • 修改态(Modified)— 此 cache 行已被修改过(脏行),内容已不同于主存,为此 cache 专-有;
  • 专有态(Exclusive)— 此 cache 行内容同于主存,但不出现于其它 cache 中;
  • 共享态(Shared)— 此 cache 行内容同于主存,但也出现于其它 cache 中;
  • 无效态(Invalid)— 此 cache 行内容无效(空行)。

多处理器时,单个 CPU 对缓存中数据进行了改动,需要通知给其他 CPU。
也就是意味着,CPU 处理要控制自己的读写操作,还要监听其他 CPU 发出的通知,从而保证 最终一致性

运行时指令重排

指令重排的场景:当 CPU 写缓存时 发现缓存区块正被其他 CPU 占用,为了提高 CPU 处理性能,可能将后面的 读缓存命令优先执行
但也并非随便重排,需要遵循 as-if-serial 语义
as-if-serial 语义的意思是指:不管怎么重排序(编译器和处理器为了提高并行速度),(单线程)程序的执行结果不能被改变。编译器,runtime 处理器都必须遵守 as-if-serial 语义。
也就是说:编译器和处理器 不会对存在数据依赖关系的操作做重排序

CPU 缓存和指令重排带来的问题
  • 缓存中的数据与主内存的数据并不是时时同步的,各 CPU(或 CPU 核心)间缓存的数据也不是实时同步。在同一个时间点,各 CPU 所看到的同一内存地址的数据的值可能是不一致的。
  • 虽然遵守了 as-if-serial 语义,但仅在但 CPU 自己执行的情况下能保证结果正确。
    多核多线程中,指令逻辑无法分辨因果关联,可能出现乱序执行,导致程序运行结果错误。
内存屏障

处理器提供了两个内存屏障指令(Memory Barrier)用于解决上述两个问题。

  • 写内存屏障(Store Memory Barrier):
    在指令后面插入 Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
    强制写入主内存,这种显示调用,CPU 就不会因为性能考虑二区对指令重排。
  • 读内存屏障(Load Memory Barrier):
    在指令前插入 Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据。
    强制读取主内存内容,让 CPU 缓存与主内存保持一致,避免了缓存导致的一致性问题。

实际上 Java 的 volatile 关键字用的就是这个原理。

线程可见性

volatile

当一个变量定义为 volatile 之后,它将具备两种特性:

  • 第一是保证此变量对所有线程的 可见性,是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
  • 第二是 禁止指令重排序优化

可能很多开发人员认为,volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是安全的。
事实上并不能保证,volatile 的可见性只保证了,对于单个 volatile 变量的写,在下一次对 volatile 变量的读可见。对于一些非原子性操作,如 i++;就不能保证线程安全。

除了 volatile 之外,Java 还有两个关键字能实现可见性:synchronized 和 final

final

被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把 “this” 的引用传递出去,那在其他线程中就能看见final字段的值

线程安全

对线程安全的实现的最有效直接的方式就是加锁。
锁的种类有很多,可以分为悲观锁、乐观锁,重量级锁、轻量级锁,可重入锁、不可重入锁,公平锁、非公平锁,阻塞锁、自旋锁…

Java 中要加锁方法有很多:

  • synchronized 关键字
  • ReentrantLock
  • ReadWriteLock
  • Automic 原子类

这些都可以实现线程安全。

题外话

这些仅仅是对 java 多线程基础知识的一个整理和总结,其中每一小块知识点都可以引申和涉及到相当多的知识。由于文章篇幅和时间限制,在此处不可能一一叙述。
而且,java 年龄已高,发展许久,如今已有非常庞大而牢固的生态圈。与其相关的内容可谓数不胜数,读者不可拘泥于此,方须知世间之大,官博广阔,方能参悟透彻。
所以,在浏览文章之时,发现概念不清晰,掌握不透彻的点,一定要去查阅资料,深入探究方可。笔者若闲暇之余,也会对其中的部分内容详细撰写。

发布了9 篇原创文章 · 获赞 123 · 访问量 4545

猜你喜欢

转载自blog.csdn.net/weixin_44051223/article/details/104660813
今日推荐