Java 多线程编程-基础知识(二)

Java 多线程编程-基础知识(二)

2020-04-14    12'39''

主播: 橙汁儿橙汁儿。

192 1

介绍:
1.并发和并行的区别 并发是指单位时间内,只有一个任务执行,而在一段时间内,是多个任务交替执行的。而并行是指单位时间内多个任务同时执行。 2.线程安全 并发时不同线程对共享变量进行读写操作,如果未进行同步,可能导致数据脏读等问题。如果多线程只是读共享变量,不会出现线程安全问题。 3.内存可见性 CPU 层面,所有的共享变量是存储在主内存中的,而 CPU 与 内存 速度存在量级差异,所以会在 CPU 中内置一个 缓存区,每个 CPU 都有自己的一级缓存,而所有的 CPU 会共享一个二级缓存。线程操作共享变量都是从主内存中拷贝一份副本到缓存中。 比如说 线程 A 和 线程 B 都要读取共享变量 i=0,首先线程 A 到自己的一级缓存中读取,没有命中;再到二级缓存中读取,还是没有命中;接下来就会去主内存中拷贝到 二级缓存,再读到一级缓存中,线程 A 将 i 的值修改为 1,并把 1 刷新到二级缓存、主内存中。接下来线程 B 尝试到自己的一级缓存中读取,没有命中;再到二级缓存中读取,把 1 读到自己的一级缓存中,接下来将 i 的值修改为 2,然后写回 二级缓存中,而 线程 A 如果再要修改 i 的值,在自己的一级缓存中就可以读到 i 的值为 1,也就是说,线程 B 对 i 的修改 对于 线程 A 而言是不可见。 因为取值只从 CPU 对应的一级缓存中取,所以会导致内存不可见的问题。 4.Java 内存模型 共享变量是存储在主内存中的,线程拥有工作内存,线程要操作共享变量需要从主内存中拷贝一份副本到自己的工作内存中进行操作,线程无法直接访问其他线程的工作内存,线程间的变量传递是通过主内存完成的。 4.synchronized 关键字 JVM 会为类和对象关联一个监视器,监视类变量和对象的实例变量。为了保证监视器的排他性,为监视器关联了锁。进入 synchronized 同步块或同步方法标志着进入一个监视区域,会自动加锁,无需开发人员手动加锁。 JVM 层面: synchronized 关键字编译后,会在同步块前后生成 monitorenter 和 monitorexit 指令,这两个指令需要一个 reference 类型的参数来指明加锁和解锁的对象,如果 synchronized 有指定锁的对象,就会将这个对象的引用赋给 reference,否则就会根据 synchronized 修饰的是 实例方法 还是 类方法 去获取相应的 reference。 在执行 monitorenter 时,先尝试获取对象的锁,如果这个对象还没有被锁定或者当前线程已经获取到对象的锁,会把锁的计时器加一,在执行 monitorexit 时,会把锁的计时器减一,当计数器为 0 时,锁会被释放。 synchronized 保证原子性 和 内存可见性。内存可见性是通过每次退出 synchronized 同步块时,会将工作内存清空;进入同步块时直接从主内存中获取共享变量。 JDK 1.6之后对 synchronized 性能做了优化,如:轻量级锁、自适应自旋、可偏向锁等,官方说法 synchronzed 性能不比 lock 差,推荐使用顺序:JUC>synchronized>lock。 5.乐观锁与悲观锁 悲观锁是指 对数据被其他线程修改持保守态度,即认为其他线程很容易修改共享变量。在对变量进行修改之前会加锁,修改的过程也是加锁的; 乐观锁是指 对数据被其他线程修改持乐观态度,认为不容易出现冲突,所以在访问记录前不会加排他锁,只有在对变量进行修改的时候才会进行冲突检测,比如 CAS 就是经典的 乐观锁。 6.CAS compare and swap,比较并交换。有 3 个重要的变量:内存地址实际存放的值;旧值/期望值;新值。 如果内存地址实际存放的值与旧值相等,说明没有被其他线程修改过,没有出现冲突,就会赋予新值;而如果值与旧值不相等,就说明被其他线程修改过,不会更新,维持旧值即可。 7.ABA问题 CAS 导致的,比如 线程 1 先将 共享变量 i 的值设置为 A ,接下来线程 B 将 i 的值设置为 B,然后又将 i 的值设置为 A,CPU 时间片切换到线程 1,这时线程 1 读到的 i 的值虽然仍是 A,但已经不是最初读到的那个 A 了,也就是说共享变量出现了环形转换的问题。可以用 AutomicStampedReference 类提供的 CompareAndSet 方法,它会为每个版本的变量生成时间戳,如果版本不一致,就会返回 false。 8.原子性 一系列操作要么全都执行,要么全都不执行,不存在只执行一部分的情况。 9.volatile 被volatile修饰的共享变量能避免指令重排序,具有内存可见性,但不保证线程安全。 (1)指令重排序:JVM 会对不存在数据依赖的指令进行重排序,对于单线程,重排序后执行结果与顺序执行的是一致的,而且优化了效率;但对于多线程,可能出现问题。 volatile 避免指令重排是指:读共享变量之后的命令不会排到读之前;写共享变量之前的命令不会排到写之后。 避免指令重排序时通过在本地代码中加入 内存屏障 实现的。 (2)内存可见性:JVM 有 8 个原子性操作,对于主内存有 lock()、unlock()、read()、write(),线程读共享变量时,需要先 read() 作用于主内存,再经过 load()到 工作内存,然后再由 use() 将变量交给执行引擎;线程写共享变量时,需要先 assign() 将操作数作用于工作内存中的变量,然后 store() ,接下来 write()到主内存中,这些操作的顺序是可以保证的,所以相当于 读 共享变量是从主内存中读取的,而 写 也是刷新回主内存,就不存在因为缓存而导致内存不可见的问题。 (3)不能保证线程安全:因为 Java 中的每条指令并不是原子性的,比如:i++,先要获取 i 的值,接下来计算 i+1的值,最后把 i+1 的值赋给 i,线程 A 先获取 i 的值为 0,接下来 CPU 切换到 线程 B ,线程 B 也获取到 i 的值 为 0,然后计算 i+1=1,将 1 赋给 i ,CPU 切换到 线程 A ,线程 A 计算 i+1=1,赋给 i ,经过两个线程,i 仍是 1,而不是期望的 2。 10.伪共享 缓存行是 CPU 与 缓存进行交互的单元,一般是 2 的幂次方字节。缓存行是连续的地址,伪共享是因为 多个变量被放入同一个缓存行,那多线程同时获取不同变量时容易出现性能下降。 可以用 @Contended 注解作用于变量,使一个变量占有一个缓存行。 11.自旋 一个线程尝试获取共享变量的锁时,发现锁已被其他线程持有,这时它不会将自己阻塞挂起,而是不放弃 CPU 使用权,循环检查锁是否被释放,多次尝试获取锁(默认是 10 次)。 12.自适应自旋 自旋的时间与对象之前自旋获取锁的情况以及获取锁的线程的状态有关。如果之前这个对象多次被自旋获取到锁,而且获取锁的线程处于运行状态,就认为自旋成功的可能性很高,会适当延迟自旋时间;而如果之前很少自旋获取到锁,会认为容易自旋失败,可能会直接忽略自旋环节。 13.轻量级锁 在 HotSpot 虚拟机中,对象头(Object header) 有两部分组成,一部分放置对象运行时相关信息,如 哈希码、GC 年龄等,这部分叫 Mark Word;另一部分放置指向方法区的指针类型,如果是数组还会有内存放数组长度。 在 Mark Word 中,有 2 bit 用于存放状态:未锁定、轻量级锁、重量级锁、偏向锁。 一个线程访问共享变量时,如果这个变量未被锁定,这个线程就会在 栈帧 开辟空间:Lock Record 锁记录,拷贝 Mark Word 的内容,尝试用 CAS 将对象的 Mark Word 指向 Lock Record,如果 CAS 成功,就说明该线程持有对象的锁;如果失败,会看对象的 Mark Word 是否指向当前线程的栈帧,如果是,说明获取锁成功,执行同步方法;如果不是,说明有其他线程获取到锁,如果有两个以上线程竞争锁,锁就会膨胀变成重量级锁,修改 2 bit 的标志位。 释放锁的过程也是 CAS 操作,尝试将对象的 Mark Word 改回原来的 拷贝在线程栈帧中的 Mark Word 初始值,如果 CAS 成功,说明同步块执行完毕,锁释放;否则说明其他线程获取到锁,只能等其他线程释放锁,还会唤醒因获取锁而被阻塞挂起的线程。 这个轻量级锁是区别于传统的“重量级锁”的,它用于避免因重量级锁互斥带来的资源开销。(线程上下文切换) 14.偏向锁 锁偏向于第一次获取到它的线程,如果启动偏向锁,会把线程 ID 存到 Mark Word 中,此后这个线程在进入同步块,无需进行同步操作,而一旦有其他线程尝试获取锁,偏向锁就会失效,根据锁当前是否被线程持有,其状态改变为 轻量级锁 或 未锁定。