java并发编程之美【二】线程基础

1 什么是多线程并发编程

并发是指同一个时间段内多个任务同时都在执行,并且都没有执行结束,而并行是说在单位时间内多个任务同时在执行 。 并发任务强调在一个时间段内同时执行,而一个时间段由多个单位时间累积而成,所以说并发的多个任务在单位时间内不一定同时在执行 。 在单 CPU 的时代多个任务都是并发执行的,这是因为单个CPU 同时只能执行一个任务 。 在单 CPU 时代多任务是共享一个 CPU 的,当 一个任务占用CPU 运行时,其他任务就会被挂起,当占用 CPU 的任务时间片用完后,会把 CPU 让给其他任务来使用,所以在单 CPU 时代多线程编程是没有太大意义的,并且线程间频繁的上下文切换还会带来额外开销 。

  • 单个 CPU 上运行两个线程,线程 A 和线程 B 是轮流使用 CPU 进行任务处理的,也就是在某个时间内单个 CPU 只执行一个线程上面的任务 。 当线程 A 的时间片用完后会进行线程上下文切换,也就是保存当前线程 A 的执行上下文,然后切换到线程 B 来占用 CPU 运行任务 。
    在这里插入图片描述
  • 双 CPU 配置,线程 A 和线程 B 各自在自己的 CPU 上执行任务,实现了真正的并行运行 。
    在这里插入图片描述

多线程编程实践中,线程的个数往往多 于 CPU 的个数,所 以一般都称多线程并发编程而不是多线程并行编程。

2 Java 中的线程安全问题

线程安全 问题是指当 多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果的问题。

3 Java 中共享变量的内存可见性问题

Java 内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工作内存中的变量 。
在这里插入图片描述
图中所示是一个双核 CPU 系统架构,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辅运算 。每个核都有自己的一级缓存,在有些架构里面还有一个所有 CPU 都共享 的二级缓存。 那么Java 内存模型里面的工作内存,就对应这里的 L1或者 L2 缓存或者 CPU 的寄存器 。

当一个线程操作共享变量时,它首先从主 内 存复制共享变量到 自己的工作内 存 , 然后对工作内存里 的变量进行处理, 处理完后将变量值更新到主内存。
那么假如线程 A 和线程 B 同时处理一个共享变量 , 会出现什么情况?我们使用图所示 CPU 架构, 假设线程 A 和线程 B 使用 不同 CPU 执行,并且当前两级 Cache 都为空 ,那么这时候由于 Cache 的存在,将会导致内存不可见 问题 , 具体看下面的分析。·

  • 线程 A 首先获取共享变量 X 的值,由于两级 Cache 都没有命中 ,所以加 载主 内 存中 X 的值,假如为 0。然后把 X=O 的值缓存到两级缓存 , 线程 A 修改 X 的值为 1,然后将其写入两级 Cache , 并且刷新到主内存 。 线程 A 操作完毕后,线程 A 所在的CPU 的两级 Cache 内和主 内 存里面 的 X 的值都是 l 。
  • 线程 B 获取 X 的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了 ,所以返回 X= 1 ; 到这里一切都是正常的 , 因为这时候主 内 存 中也是 X= l 。然后线程 B 修改 X 的值为 2 , 并将其存放到线程 2 所在 的一级 Cache 和共享二级 Cache 中,最后更新主内存中 X 的值为 2 : 到这里一切都是好的 。·
  • 线程 A 这次又需要修改 X 的值 , 获取 时 一级缓存命 中 , 并且 X= l ,到 这里 问 题就出 现了,明明线程 B 已经把 X 的值修改为了 2,为何线程 A 获取的还是 l 呢? 这就是共享变量的内存不可见 问 题 , 也就是线程 B 写入的值对线程 A 不可见。
    那么 如何解决共享变量内存不可见问题? 使用 Java 中的 volatile 关键字就可 以解决这个 问题

4 Java 中 的 synchronized 关键字

synchronized 块是 Java 提供的一种原子性内置锁, Java 中的每个对象都可以把它当作一个同步锁来使用 , 这些 Java 内置的使用者看不到的锁被称为内部锁 ,也 叫作监视器锁

线程的执行代码在进入 synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该 内置锁资源的 wait 系列方法时释放该内置锁 。

内置锁是排它锁 ,也就是当一个线程获取这个锁后 , 其他线程必须等待该线程释放锁后才能获取该锁 。

由于 Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而 synchronized 的使用就会导致上下文切换

synchronized 的内存语义

进入 synchronized 块的内存语义是把在 synchronized 块内使用到的变量从线程的工作内存中清除,这样在 synchronized 块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取 。 退出 synchronized 块的内存语义是把在 synchronized 块内对共享变量的修改刷新到主内存

其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主 内 存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存 。

5 Java 中的 volatile 关键字

使用锁的方式可以解决共享变量 内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销 。 对于解决内存可见性问题, Java 还提供了 一种弱形式的同步,也就是使用 volatile 关键字 。 该关键字可以确保对一个变量的更新对其他线程马上可见 。 当 一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存 。 当其他线程读取该共享变量时 ,会从主内存重新获取最新值,而不是使用当前线程的工作 内存中的值。

volatile 虽然提供了可见性保证,但并不保证操作的原子性 。

那么 一般在什么时候才使用 volatile 关键字呢?

  • 写入变量值不依赖变量的当前值时 。因为如果依赖当前值,将是获取–计算–写入三步操作,这三步操作不是原子性的,而 volatile 不保证原子性 。
  • 读写变量值时没有加锁 。 因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为 volatile 的 。

6 Java 中的原子性操作

所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行 , 要么全部不执行,不存在只执行其中 一部分的情况 。

7 Java 中的 CAS 操作

当 一个线程没有获取到锁时会被阻塞挂起 , 这会导致线程上下文的切换和重新调度开销 。Java 提供了非阻塞的 volatile 关键字来解决共享变量的可见性问题 , 这在 一 定程度上弥补 了 锁带来的开销 问 题,但是 volatile 只能保证共享变量 的可见性,不能解决读改一写等的原子性 问 题 。 CAS 即Compare and Swap,其是 JDK 提供的非阻塞原子性操作 ,它通过硬件保证了比较更新操作的原子性 。

8 Unsafe 类

9 Java 指令重排序

Java 内存模型允许编译器和处理器对指令重排序以提高运行性能 , 并且 只 会对不存在数据依赖性的指令重排序。
重排序在多线程下会导致非预期的程序执行结果 ,而使用 volatile 修饰 就可以避免重排序和内存可见性问题 。

  • 写 volatile 变量时,可以确保 volatile 写之前的操作不会被编译器重排序到 volatile写之后 。
  • 读 volatile 变量时,可以确保 volatile 读之后的操作不会被编译器重排序到 volatile读之前 。

10 伪共享

为了解决计算机系统中主内存与 CPU 之间运行速度差问题,会在 CPU 与主内存之间添加一级或者多级高速缓冲存储器( Cache )。这个 Cache 一般是被集成到 CPU 内部的,所以也叫 CPU Cache.图 所示是两级 Cache 结构。
在这里插入图片描述
在 Cache 内 部是按行存储的,其中每一行称为一个 Cache 行。 Cache 行是 Cache 与主内存进行数据交换的单位, Cache 行的大小一般为 2 的幕次数字节。
在这里插入图片描述
当 CPU 访问某个变量时,首先会去看 CPU Cache 内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个 Cache 行大小的内存复制到 Cache 中 。 由于存放到 Cache 行的是内存块而不是单个变量,所以可能会把多个变量存放到一个 Cache 行中 。 当多个线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比将每个变量放到一个缓存行,性能会有所下降,这就是伪共享

伪共享的产生是因为多个变量被放入了一个缓存行中,并且多个线程同时去写入缓存行中不同的变量 。
在单个线程下顺序修改一个缓存行中的多个变量,会充分利用程序运行的局部性原则,从而加速了程序的运行。而在多线程下并发修改一个缓存行中的多个变量时就会竞争缓存行,从而降低程序运行性能 。

JDK 8 提供了 一个 sun.misc.Contended 注解,用来解决伪共享问题

11 锁的概述

1 乐观锁与悲观锁

悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态 。 悲观锁的实现往往依靠数据库提供的锁机制,即在数据库 中 ,在对数据记录操作前给记录加排它锁 。 如果获取锁失败, 则说明数据正在被其他线程修改 , 当前线程则等待或者抛出 异常。 如果获取锁成功,则对记录进行操作 ,然后提交事务后释放排它锁 。

乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测 。乐观锁并不会使用数据库提供的锁机制, 一般在表中添加 version 字段或者使用业务状态来实现 。 乐观锁直到提交时才锁定,所以不会产生任何死锁

2 公平锁与非公平锁

根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁,公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁 。而非公平锁则在运行时闯入,也就是先来不一定先得 。
ReentrantLock 提供了公平和非公平锁的实现 。

  • 公平锁: ReentrantLock pairLock =new ReentrantLock(true)
  • 非公平锁: ReentrantLock pairLock =new ReentrantLock(false) 。 如 果构造函 数不传递参数,则默认是非公平锁 。

在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销 。

3 独占锁与共享锁

根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁 。
独占锁保证任何时候都只有一个线程能得到锁, ReentrantLock 就是以独占方式实现的 。 共享锁则可以同时由多个线程持有 ,例如 ReadWriteLock 读写锁,它允许一个资源可以被多线程同时进行读操作 。

独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由 一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取 。

共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作 。

4 可重入锁

当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己己经获取的锁时是否会被阻塞呢?如果不被阻塞,那么我们说该锁是可重入的,也就是只要该线程获取了该锁,那么可以无限次数(在高级篇中我们将知道,严格来说是有限次数)地进入被该锁锁住的代码 。

synchronized 内部锁是可重入锁
可重入锁的原理是在锁内部维护一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为 0,说明该锁没有被任何线程占用 。 当 一个钱程获取了该锁时,计数器的值会变成 1 ,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。
但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加+1,当释放锁后计数器值 -1 。 当计数器值为 0 时,锁里面的线程标示被重置为 null , 这时候被阻塞的线程会被唤醒来竞争获取该锁 。

5 自旋锁

由于 Java 中的线程是与操作系统中的线程一一对应的,所以当一个线程在获取锁(比如独占锁)失败后,会被切换到内核状态而被挂起 。 当该线程获取到锁时又需要将其切换到内核状态而唤醒该线程 。 而从用户状态切换到内核状态的开销是比较大的,在一定程度上会影响并发性能。
自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃 CPU 使用权的情况下,多次尝试获取(默认次数是 10,可以使用 -XX :PreBlockSpinsh 参数设置该值),很有可能在后面几次尝试中其他线程己经释放了锁 。 如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起 。 由此看来自旋锁是使用 CPU 时间换取线程阻塞与调度的开销,但是很有可能这些 CPU 时间 白白浪费了 。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值