《Java并发编程之美》读书笔记一

一、多线程并发编程的理解

  • 并发与并行

并发是指同一个时间段内多个任务同时都在执行【都没有执行结束】。
并行是指单位时间内多个任务同时在执行。

  • 并发任务强调在一个时间段内同时执行,而 一个时间段由多个单位时间累计而成,所以说并发的多个任务在单位时间内不一定同时执行。
  • 单CPU时代多个任务都是并发执行的,因为单个CPU同时只能执行一个任务。多任务共享一个CPU,一个任务占用CPU时其他任务挂起。

单个CPU 运行多个线程时,多个线程之间轮流使用CPU。
多个CPU运行多个线程时,线程数如果和CPU核数相同,就可以达到并行运行。
而一般的多线程编程时,线程的数量往往多于CPU的核数,所以称之为多线程并发编程而不是并行编程。

二、Java中的线程安全问题

多核CPU打破了单核CPU的限制,减少了线程上下文切换的开销,随着应用吞吐量的提高,对于海量数据或者请求的处理则将线程安全的问题凸显出来。

  • 线程安全问题之----- 共享变量

被多个线程所持有或者说多个线程都可以去访问该资源。

  • 什么是线程安全问题?

指多个线程同时读写一个资源并且没有任何的同步措施的时候,导致出现脏数据或者其他不可预见的结果的问题【只有当至少一个线程修改共享资源的时候才会存在线程安全问题】。

1、Java中共享变量的内存可见性问题
  • 首先看一下 Java 内存模型:
    Java内存模型

Java 内存模型规定: 所有的变量都放在主内存,当线程使用变量的时候,会把主内存里面的变量复制到自己的工作空间或者叫做工作内存中,线程读写变量的时候操作的是自己工作内存中的变量。

  • 实际实现的工作内存是下面这样:
    实际

上图是一个多核的系统,每个核都有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算数逻辑运算。每个核都有自己的一级缓存,有些系统中还有图中所展示的所有CPU 共享的二级缓存

  • Java内存模型 里面的工作内存,就是对应着L1 、L2缓存或者 CPU的寄存器。
  • 共享变量内存不可见问题:
    复现
2、Java中的Synchronized关键字
  • synchronized关键字介绍:

synchronized是 java 提供的一种原子性内置锁【内置锁是排他锁】。
Java中的每个对象都可以把synchronized 当做同步锁来使用,Java内置对象看不到的被称为内部锁,也叫做监视器锁。
当线程的执行代码在进入synchronized 代码块之前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步代码块内调用了该内置锁资源的wait 系列方法的时候释放该内置锁。

  • 也就是 说当一个线程获取到这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。
  • Java中的线程与操作系统的线程是 一 一 对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,
  • synchronized的内存语义:

synchronized 可以解决共享变量内存可见性问题,进入synchronized块的内存语义是把在synchronized 块内使用到的变量从线程的工作内存中清除,这样在synchronized 块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出synchronized 块的语义是把在synchronized 块内对共享变量的修改刷新到主内存。【其实这也是加锁和释放锁的语义】

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

除了 可以解决共享变量可见性问题外,synchronized 经常用来实现原子性操作 ,另外,synchronized关键字会引发线程上下文切换并带来线程调度开销 。

3、Java中的volatile关键字

使用synchronized 锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销。

  • 对于解决内存可见性问题,Java提供了一种弱形式的同步【volatile关键字】。
  • volatile 关键字可以确保对一个变量的更新对其他线程马上可见 。当一个变量被声明为 volatile时 线程不会把值 缓存在寄存器或者其他缓存中,而是把值刷新回主内存。当其他变量读取该共享变量的时候会从主内存重新获取最新值。而不是使用当前线程工作内存中的值,
  • volatil 的 内存语义:

当线程写入了volatile变量值时就等价于线程退出 synchronized 块【把写入工作内存中的变量值 同步回主内存】。
读取volatile 变量值时就相当于进入synchronized 块【清空本地内存变量值,在从主内存获取最新值】。

4、synchronized和volatile比较

二者都可以解决内存可见性问题,但是前者是独占锁,同时只能有一个线程调用 get 方法,其他调用线程会被阻塞,同时会存在线程上下文切换的和线程重新调度的开销。这也是使用锁方式不好的地方 。而后者是非阻塞算法。不会造成线程上下文切换的开销。

此外,volatile 关键字只可以保证可见性,但是不保证操作的原子性。

什么时候使用volatile 关键字呢?

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

三、Java 中的原子操作

  • 原子操作是指执行一系列操作的时候,这些操作要么全部执行,要么全部不执行。不存在只执行其中一部分的情况。【i++ 就不具备原子性】。

使用synchronized 关键字可以实现线程安全性【即内存可见性 和 原子性】,但是排它锁 降低了并发性 ,而 volatile 又无法保证原子性 ,那么怎么处理呢?
我们可以使用非阻塞CAS算法实现的原子类。

1、Java 中的CAS操作

Java 中 使用锁处理并发不好的地方就是: 当一个线程没有获取到锁的时候会被挂起。— 会导致线程上下文的切换和重新调度开销。虽然Java 提供了volatile 关键字来解决共享变量不可见问题,这在一定程度上弥补了锁带来的开销问题,但是volatile 只能保证共享变量的可见性,无法保证原子性问题。CAS【Compare and swap 】,是JDK 提供的非阻塞原子性操作,通过硬件保证了比较-更新操作的原子性。JDK的UNSafe 类提供了一系列的CAS操作。

  • compareAndSwapLong(Object obj,long valueOffset,long expect,long update)
  • 参数的意思分别是: 对象内存位置、对象中的变量的偏移量、变量预期值和新的值
  • 操作含义是: 如果对象obj内内存偏移量为valueOffset的变量值为expect,则使用新的值update 替换 expect,这是处理器提供的一个原子性指令。

  • CAS ☞☞☞☞☞☞☞☞☞☞ABA问题

ABA 问题产生的原因是变量的状态值产生了环形转换,就是变量的值可以从A-B 在从B-A,如果变量的值只能朝着一个方向转换,就不会出现问题,JDK中的AtomicStampedReferenced类给每个变量的状态值都配备了一个时间戳,从而避免了ABA问题。

四、Unsafe类

  • JDK 的rt.jar 包中的 Unsafe类提供了硬件级别的原子性操作,Unsafe类的所有方法都是native 方法,使用JNI的方式调用本地C++ 实现库。
  • 看一下重要的几个方法:
public native long objectFieldOffset(Field var1);

返回指定的变量在所属类中的内存偏移地址,该偏移地址仅仅在该Unsafe函数中访问指定时间段时使用,

public native int arrayBaseOffset(Class<?> var1);

获取数组中第一个元素的地址。

long getAndSetLong(Object obj, long offset, long update)       

获取对象obj汇总偏移量为offset的变量的Volatile语义的当前值,并设置变量Volatile 语义的值为update。

五、Java指令重排序

  • Java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。
  • a =1 ;b = 2 c = a+ b;
  • c 依赖 a、b 所以 a b 一定会在c 之前 但是 a b 之间可能会重排序。

六、锁的概述

  • 乐观锁与悲观锁

和数据库中的类似:
悲观锁: 对数据被外界修改持保守态度,认为数据易被其他线程修改,所以在数据被处理前先对数据加锁,整个处理过程中始终处于锁定状态,【主要依靠数据库提供的锁机制】。
乐观锁就是认为数据不是那么容易被其他线程修改,所以在访问数据前不会加排它锁,而是在数据进行提交更新的时候,才会正式对数据冲突与否进行检测。一般使用version的方式去实现,类似于CAS的操作,如果有一个线程更新成功了,其他线程可以选择什么都不做,也可以选择重试一定的次数,这个重试也类似于CAS的自旋操作。


  • 公平锁与非公平锁

根据线程获取锁的抢占机制进行分类:

  • 公平锁:表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求的锁的线程将最早获取到锁。
  • 非公平锁: 可以运行时闯入,即,先到未必先得。
    ReentrantLock pairLock = new ReentrantLock(true);
    设置我 true 即为 公平锁。
    ReentrantLock pairLock = new ReentrantLock(fasle);
    设置为false 即为 非公平锁。
    不传递参数默认就是非公平锁。
  • 在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。

  • 独占锁与共享锁:

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

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

共享锁是一种乐观锁,允许多个线程同时进行读操作。


  • 可重入锁:
  • 当一个线程要获取被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己的已经获取的锁时会不会被阻塞呢?
  • 如果不被阻塞,这就说明是可重入的,也就是说线程获取到了锁,那么可以无限次被该锁锁住的代码【严格讲是有限次】。

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


  • 自旋锁:

因为java 中的线程与操作系统的线程一一对应,而用户状态的线程和内核状态的线程的切换开销是比较大的,如果一个线程获取锁失败则会切换到内核态进行阻塞,如果获取锁成功又需要将其切换到内核态而唤醒该线程。所以自旋锁就是: 在线程获取锁失败的时候不马上阻塞自己,在不放弃CPU使用权的前提下,多次尝试获取,一般默认10次,有可能尝试几次就获取到了,没获取到就浪费CPU的时间【自旋锁使用CPU时间换取线程阻塞与调度的开销】。

发布了122 篇原创文章 · 获赞 32 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/YangzaiLeHeHe/article/details/100135578