JUC篇

JUC是java.util.concurrent包的简称,在Java1.5添加,目的就是为了更好的支持高并发任务。让开发者进行多线程编程时减少竞争条件和死锁的问题。

1、虚假唤醒

1.1、导致原因

假设有线程A、B、C、D四个去操作一个资源number(number值为0),A、B线程执行加一操作,C、D线程执行减一操作。

 public class Data {
 
  // 表示数据个数
  private int number = 0;
 
  public synchronized void increment() throws InterruptedException {
  if (number != 0) {
  this.wait();
  }
  number++;
  System.out.println(Thread.currentThread().getName() + "生产了数据:" + number);
  this.notify();
  }
 
  public synchronized void decrement() throws InterruptedException {
  if (number == 0) {
  this.wait();
  }
  number--;
  System.out.println(Thread.currentThread().getName() + "消费了数据:" + number);
  this.notify();
  }
 }

 

 public class Test {
 
  public static void main(String[] args) {
  Data data = new Data();
  //生产者线程A
  new Thread(() -> {
  for (int i = 0;i < 5;i++) {
  try {
  data.increment();
  } catch (InterruptedException e) {
  e.printStackTrace();
  }
  }
  },"A").start();
 
  //生产者线程B
  new Thread(() -> {
  for (int i = 0;i < 5;i++) {
  try {
  data.increment();
  } catch (InterruptedException e) {
  e.printStackTrace();
  }
  }
  },"B").start();
 
  //消费者线程C
  new Thread(() -> {
  for (int i = 0;i < 5;i++) {
  try {
  data.decrement();
  } catch (InterruptedException e) {
  e.printStackTrace();
  }
  }
  },"C").start();
 
  //消费者线程D
  new Thread(() -> {
  for (int i = 0;i < 5;i++) {
  try {
  data.decrement();
  } catch (InterruptedException e) {
  e.printStackTrace();
  }
  }
  },"D").start();
  }
 }

执行步骤:

  1. A抢到锁执行++。number值为1。

  2. A执行notify()发现没有线程在waiting状态,拿着锁继续执行循环,A判断后执行wait()释放锁进入waiting状态阻塞。number值为1。

  3. B抢到锁,B判断后执行wait()释放锁进入waiting状态阻塞。number值为1。

  4. C抢到锁执行--。number值为0。

  5. C执行notify()唤醒了A,A从waiting状态阻塞回来,因为在进入waiting状态阻塞前执行了if判断了,所以马上执行if判断下面的++。number值为1。

  6. A执行notify()唤醒了B,B也从waiting状态阻塞回来,也因为在进入waiting状态阻塞前执行了if判断了,所以马上执行if判断下面的++。number值为2。

所以根据以上实验说明wait会在“哪里跌倒就在哪里爬起来”,又因为if判断只会判断一次,所以存在虚假唤醒。

1.2、解决方法

将if判断更换为while即可。因为如果是while的话,其while本身是个循环体,被唤醒后会再去执行循环再去验证循环条件是否成立,如果成立了则再次执行wait()释放锁进入waiting状态阻塞。

2、精准唤醒

Condition是在java 1.5中才出现的,它用来替代传统的Object的wait()、notify()实现线程间的协作,相比使用Object的wait()、notify(),使用Condition的await()、signal()这种方式实现线程间协作更加安全和高效。可以解决唤醒指定线程的操作问题。

方法:

  • 等待:condition.await()

  • 唤醒:condition.signal()

  • 唤醒全部:condition.signalAll()

2.1、案例分析

 public class Data {
     private int number = 1;
     
     private Lock lock = new ReentrantLock();
     Condition condition = lock.newCondition();
 
     private Condition condition1 = lock.newCondition();
     private Condition condition2 = lock.newCondition();
     private Condition condition3 = lock.newCondition();
 
     public void printA(){
         lock.lock();
         try {
             //业务->判断-》执行-》通知
             while(number!=1)
            {
                 condition1.await();
            }
             System.out.println(Thread.currentThread().getName()+"->AAAAAAAAAAAAAAAA");
             number = 2;
             condition2.signal();
        } catch (Exception e) {
             e.printStackTrace();
        } finally {
             lock.unlock();
        }
    }
     public void printB(){
         lock.lock();
         try {
             //业务->判断-》执行-》通知
             while(number!=2)
            {
                 condition2.await();
            }
             System.out.println(Thread.currentThread().getName()+"->BBBBBBBBBBBBBBBB");
             number = 3;
             condition3.signal();
        } catch (Exception e) {
             e.printStackTrace();
        } finally {
             lock.unlock();
        }
    }
     public void printC(){
         lock.lock();
         try {
             //业务->判断-》执行-》通知
             while(number!=3)
            {
                 condition3.await();
            }
             System.out.println(Thread.currentThread().getName()+"->AAAAAAAAAAAAAAAA");
             number = 1;
             condition1.signal();
        } catch (Exception e) {
             e.printStackTrace();
        } finally {
             lock.unlock();
        }
    }
 
 }
 public class Test {
     public static void main(String[] args) {
 
         Data data = new Data();
         new Thread(()->{
             for (int i = 0; i < 10; i++) {
                 data.printA();
            }
        }, "A").start();
         new Thread(()->{
             for (int i = 0; i < 10; i++) {
                 data.printB();
            }
        }, "B").start();
         new Thread(()->{
             for (int i = 0; i < 10; i++) {
                 data.printC();
            }
        }, "C").start();
 
 
    }
 }

以上案例就可以实现线程的精准唤醒通知。

2.2、Condition与Object的通知/协作方法比较

在这里插入图片描述

 

3、锁的对象

  • 同步方法:对象锁,锁的是调用这个方法的对象。

  • 静态同步方法:类锁,锁的是调用这个方法的对象类模板对象,也就是Class对象(一个类只有一个类模板对象)。

  • 普通方法:没有锁。

4、集合中的线程安全问题

想要使用线程安全的集合可以使用相关安全的集合类或者使用Collections.synchronized...()方法去创建一个集合,这些都是synchronized关键字修饰的方式。还可以使用另一种方式:JUC包下的根据复制写入方式去避免线程安全问题的集合。

4.1、CopyOnWriteArrayList

在JUC包下面有一个CopyOnWriteArrayList类,它是一个适用于高并发下的集合,线程安全的。当元素需要被修改的时候,并不直接修改原有数组对象,而是对原有数据进行一次拷贝,将修改的内容写入副本中。写完之后,再将修改完的副本替换成原来的数据,这样即可以保证写操作不会影响读操作,也避免了多线程写入时可能会造成的数据覆盖的问题了。

4.2、CopyOnWriteArraySet

原理同上。当元素需要被修改的时候,并不直接修改原有数组对象,而是对原有数据进行一次拷贝,将修改的内容写入副本中。写完之后,再将修改完的副本替换成原来的数据。

4.3、ConcurrentHashMap

4.3.1、JDK1.7的底层

在JDK1.7中ConcurrentHashMap采用了数组+Segment+分段锁的方式实现。

Segment(分段锁):ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

内部结构:ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。如下图是ConcurrentHashMap的内部结构图:

img

从上面的结构我们可以了解到,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作。第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。

4.3.2、JDK1.8的底层

JDK8中ConcurrentHashMap参考了JDK8 HashMap的实现,采用了数组+链表+红黑树的实现方式来设计,内部大量采用CAS操作。并发控制使⽤synchronized 和 CAS 来操作。(JDK1.6 以后 对 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但 是已经简化了属性,只是为了兼容旧版本;

JDK1.8的Nod节点中value和next都用volatile修饰,保证并发的可见性。

可以理解为,synchronized 只锁定当前链表或红⿊⼆叉树的⾸节点,这样只要 hash 不冲突,就不会产⽣并发,效率⼜提升 N 倍。

img

4.3.3、ConcurrentHashMap与Hashtable的区别

Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采⽤ 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突⽽存在的;

Hashtable(同⼀把锁) :使⽤ synchronized 来保证线程安全,效率⾮常低下。当⼀个线程访问同步⽅法时,其他线程也访问同步⽅法,可能会进⼊阻塞或轮询状态,如使⽤ put 添加元素,另⼀个线程不能使⽤ put 添加元素,也不能使⽤get,竞争会越来越激烈效率越低;

总结一下:

  • ConcurrentHashMap不论1.7还是1.8,他的执行效率都比HashTable要高的多,主要原因还是因为Hash Table使用了一种全表加锁的方式。

 

img

5、JUC的三大常用辅助类

5.1、CountDownLatch(减法计数器)

一个线程计数器,用于在一个或多个线程执行完之后再去执行接下来的步骤时使用。

 //相当于计数器
 CountDownLatch countDownLatch = new CountDownLatch(5);
 //计数器总数是5,当减少为0,任务才继续向下执行
 for (int i = 0; i < 6 ; i++) {
  new Thread(() -> {
  System.out.println(Thread.currentThread().getName()+"==>start");
  countDownLatch.countDown();
  }).start();
 }
 // 等待归零
 countDownLatch.await();
 System.out.println("main线程继续向下执行");

原理:

  • countDown():数量减1。

  • await():等待计数器归零,然后再向下执行。

  • 每次有线程调用countDown()数量-1,假设计数器变为0,countDownLatch.await();就会被唤醒,继续执行。

5.2、CyclicBarrier(加法计数器)

可以设定一个值,让其一个或多个线程执行完之后再去执行预先设定好的业务方法。

 /**
  * 集齐77个龙珠召唤神龙
  */
 // 召唤龙珠的线程
 CyclicBarrier cyclicBarrier = new CyclicBarrier(7, ()->{
  System.out.println("召唤神龙成功! ");
 });
 for (int i = 0; i < 7; i++) {
  int temp = i;
  //lambda 能拿到i吗
     new Thread(()->{
    System.out.println(Thread.currentThread().getName() + "收集" + temp + "个龙珠");
  try {
  cyclicBarrier.await();
  } catch (InterruptedException e) {
  e.printStackTrace();
  } catch (BrokenBarrierException e) {
  e.printStackTrace();
  }
  }).start();
 }

5.3、Semaphore(信号量)

用来控制同时访问特定资源的线程数量,通过协调各个线程,以保证合理的使用资源。

 Semaphore semaphore = new Semaphore(3);
 
 for (int i = 0; i < 6; i++) {
  int temp = i;
  new Thread(() -> {
  try {
  semaphore.acquire(); //获取
  System.out.println(temp + "号车抢到车位");
  TimeUnit.SECONDS.sleep(5);
  } catch (InterruptedException e) {
  e.printStackTrace();
  } finally {
  semaphore.release(); //释放
  System.out.println(temp + "号车离开车位");
  }
  }).start();
 }
  • acquire():获取信号量,假设如果已经满了,等待信号量可用时被唤醒。

  • release():释放信号量。

  • 作用:多个共享资源互斥的使用!并发限流,控制最大的线程数。

5.4、CountDownLatch与CyclicBarrier的区别

  • CountDownLatch 是一次性的,CyclicBarrier 是可循环利用的

  • CountDownLatch 参与的线程的职责是不一样的,有的在倒计时,有的在等待倒计时结束。CyclicBarrier 参与的线程职责是一样的。

6、ReadWriteLock(读写锁)

  • 读锁:readLock。同一时刻允许多个线程对共享资源进行读操作;当进行读操作时,同一时刻所有线程的写操作会被阻塞。对于读锁而言,由于同一时刻可以允许多个线程访问共享资源,进行读操作,因此称它为共享锁;

  • 写锁:writeLock。同一时刻只允许一个线程对共享资源进行写操作;当进行写操作时,同一时刻其他线程的读操作会被阻塞;对于写锁而言,同一时刻只允许一个线程访问共享资源,进行写操作,因此称它为排他锁。

7、Queue队列

7.1、BlockingQueue阻塞队列

可以使用阻塞等待的相关方法使其在超过原本设定的容量界限后阻塞等待,直到满足进入容量界限的条件为止,或者设定超时时间让其在一定时间内未满足条件就自动退出阻塞状态。

方式可能会出现异常不会抛异常,有返回值阻塞等待超时阻塞等待
添加 add() offer() put() offer(obj, timeout, timeUnit)
移除 remove() poll() take() poll(timeout, timeUnit)
获取队首元素 element() peek()    

7.2、SynchronousQueue同步队列

SynchronousQueue没有容量,是无缓冲等待队列,是一个不存储元素的阻塞队列,必须等队列中的添加元素被take取出后才能继续put添加新的元素。

8、池化技术及线程池

8.1、池化技术

提前保存大量的资源,以备不时之需以及重复使用。池化技术应用广泛,如内存池、线程池、连接池等等。由于在实际应用当做,分配内存、创建进程、线程都会设计到一些系统调用,系统调用需要导致程序从用户态切换到内核态,是非常耗时的操作。因此,当程序中需要频繁的进行内存申请释放,进程、线程创建销毁等操作时,通常会使用内存池、进程池、线程池技术来提升程序的性能。对连接或线程的复用,并对复用的数量、时间等进行控制,从而使得系统的性能和资源消耗达到最优状态。

8.2、线程池

8.2.1、背景

经常创建和销毁、使用量特别大的资源,比如并发情况下的线程,对性能影响很大。

8.2.2、思路

提前创建好多个线程,放入线程池中,使用时直接获取,使用完再放回池中。可以避免频繁的创建销毁、实现重复利用。类似生活中的公共交通工具。

8.2.3、好处

  • 提高响应速度(减少了创建新线程的时间)。

  • 降低资源消耗(重复利用线程池中的线程,不需要每次的创建)。

  • 便于线程管理,管理方法:

    • corePoolSize:核心池大小

    • maximumPoolSize:最大线程数

    • keepAliveTime:线程没有任务时最多保持多长时间后会终止

8.2.4、线程池相关API

8.2.4.1、ExecutorService

真正的线程池接口。常见子类ThreadPoolExecutor。

  • void execute(Runnable command):执行任务/命令,没有返回值,一般用来执行Runnable。

  • <T> Future<T> submit(Callable<T> task):执行任务,有返回值,一般用来执行Callable。

  • void shutdown():关闭连接池。

8.2.4.2、Executors

工具类、线程池的工厂类,用于创建并返回不同类型的线程池。自动创建线程池方式。

例如:

 ExecutorService service = Executors.newCachedThreadPool();
8.2.4.3、ThreadPoolExecutor

使用ThreadPoolExecutor有助于大家明确线程池的运行规则,创建符合自己的业务场景需要的线程池,避免资源耗尽的风险。手动创建线程池方式。

8.2.4.3.1、七大参数(构造参数)详解
 public ThreadPoolExecutor(int corePoolSize, 
                           int maximumPoolSize,
                           long keepAliveTime,
                           TimeUnit unit,
                           BlockingQueue<Runnable> workQueue,
                           ThreadFactory threadFactory,
                           RejectedExecutionHandler handler) {
     // ...
 }
  • corepoolsize:最小线程数,一般设置为服务器核心数量。

  • maxpoolsize:最大线程数,当队列满后会扩容到最大线程数。

  • keepalivetime:当线程池中线程数大于corepoolsize 并且无新增任务时,销毁等待的最大时间。

  • unit:销毁时间单位。

  • workQueue:一个阻塞队列,用来存储等待执行的任务,这个参数的选择也很重要,会对线程池的运行过程产生重大影响,一般来说,这里的阻塞队列有以下几种选择:ArrayBlockingQueue; LinkedBlockingQueue; SynchronousQueue; PriorityBlockingQueue ArrayBlockingQueue和PriorityBlockingQueue使用较少,一般使用LinkedBlockingQueue和SynchronousQueue。线程池的排队策略与BlockingQueue有关。

  • threadFactory:线程工厂,主要用来创建线程。

  • handler:拒绝策略(饱和策略),当现在同时进行的任务数量大于最大线程数量并且队列也已经放满后,线程池采取的一些策略。

    • AbortPolicy:直接抛出异常,RejectedExecutionException。

    • CallerRunsPolicy :调用执行自己的线程来完成任务,当您的任务不能抛弃时采取这个策略,这个策略会给系统带来额外的开销,影响性能。

    • DiscardPolicy:直接抛弃掉。

    • DiscardOldestPolicy:丢弃掉最老的任务。

8.2.4.3.2、maxpoolsize参数定义规则
  • CPU密集型:根据CPU的核心数 + 1来定义。如12核CPU就可以设置maxpoolsize=12 + 1,保证CPU的效率最高。

    JAVA动态获取CPU核心数得方法Runtime.getRuntime().availableProcessors()。适用于需要参与运算的多任务就用这种方式。

  • I/O密集型:根据CPU的核心数*2来定义。适用于需要进行大量的磁盘、内存的读/写操作或者网络请求的多任务就用这种方式。

8.2.4.4、execute流程说明

img

  1. 如果线程池中的线程数量少于corePoolSize,就创建新的线程来执行新添加的任务。

  2. 如果线程池中的线程数量大于等于corePoolSize,但队列workQueue未满,则将新添加的任务放到workQueue中。

  3. 如果线程池中的线程数量大于等于corePoolSize,且队列workQueue已满,但线程池中的线程数量小maximumPoolSize,则会创建新的线程来处理被添加的任务。

  4. 如果线程池中的线程数量等于了maximumPoolSize,就用RejectedExecutionHandler来执行拒绝策略。

8.2.4.5、六大线程池
名称描述
FixedThreadPool 固定数量线程池。可控制线程最大并发数,超出的线程在队列中等待。
SingleThreadExecutor 单线程化的线程池。只会用唯一的工作线程来执行任务,保证任务按照队列顺序来执行。
CachedThreadPool 可缓存线程池。只有提交了任务时才会启动一个线程,一开始是没有线程的 ,来一个任务就开启一个线程。当然前提是线程池里没有空闲的并且存活的线程,另外如果一个线程如果在60s内没有被使用 则会被杀死。
ScheduledThreadPool 定时线程池。支持定时及周期性的任务执行。
SingleThreadScheduledExecutor 单线程化的定时线程池。只会用唯一的工作线程来执行任务,保证任务按照队列顺序来执行。支持定时及周期性的任务执行。
WorkStealingPool 任务窃取线程池。不保证执行顺序,适合任务耗时差异较大。每个线程都有自己维护的队列,当一个线程处理完自己的队列后,会去窃取其他线程的任务队列进行处理。

8.2.5、ForkJoin

主要用来使用分治法(Divide-and-Conquer Algorithm)来解决问题。ForkJoinPool的优势在于,可以充分利用多cpu,多核cpu的优势,把一个任务拆分成多个“小任务”分发到不同的cpu核心上执行,执行完后再把结果收集到一起返回。

9、JMM

JMM即Java内存模型(Java memory model),JMM是用来定义一个一致的、跨平台的内存模型,是缓存一致性协议,用来定义数据读写的规则。

9.1、内存划分

JMM规定了内存主要划分为主内存和工作内存两种。此处的主内存和工作内存跟JVM内存划分(堆、栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是Java堆中的对象实例部分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。

img

在Java中,不同线程拥有各自的私有工作内存,当线程需要读取或修改某个变量时,不能直接去操作主内存中的变量,而是需要将这个变量拷贝到该线程的工作内存变量副本中,当该线程修改其变量副本的值后,其它线程并不能立刻读取到新值,需要将修改后的值刷新到主内存中,其它线程才能从主内存读取到修改后的值

9.2、内存的八种交互

  • lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态。

  • unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

  • load(载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中。

  • use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令。

  • assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中。

  • store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用。

  • write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

JMM对这八种指令的使用,制定了如下规则:

  • 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write。

  • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存。

  • 不允许一个线程将没有assign的数据从工作内存同步回主内存。

  • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作。

  • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁。

  • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值。

  • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量。

  • 对一个变量进行unlock操作之前,必须把此变量同步回主内存。

10、Volatile

想要线程安全必须保证可见性、原子性、有序性。而volatile只能保证可见性和有序性。

10.1、名词说明

  • 可见性:一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量的这种修改(变化)。

  • 原子性:一个操作或多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。原子性就像数据库里面的事务一样,他们是一个团队,同生共死。

  • 指令重排:在程序执行过程中,为了性能考虑,编译器和CPU可能会对指令重新排序。

10.2、特性

  • 保证可见性:当一个共享变量被volatile修饰时,它会保证线程修改的值会从该线程的私有工作内存中立即被更新到主存中,当有其他线程需要读取时,它会去主存中读取新值。另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

  • 不保证原子性:

    不存在原子性操作的例子:

     public class Test {
      public volatile int inc = 0;
     
      public void increase() {
      inc++;
      }
     
      public static void main(String[] args) {
      final Test test = new Test();
      for (int i = 0; i < 10; i++) {
      new Thread(() -> {
      for (int j = 0; j < 1000; j++)
      test.increase();
      }).start();
      }
      while (Thread.activeCount() > 1)  //保证前面的线程都执行完
      Thread.yield();
      System.out.println(test.inc);
      }
     
     }

    解析(自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行。):

    • 线程1先读取了变量inc的原始值,然后线程1被阻塞了;

    • 线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值为10,然后进行加1操作,并把11写入工作内存,最后写入主存。

    • 线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。

    • 那么两个线程分别进行了一次自增操作后,inc只增加了1。

    对于这种情况可以使用JUC下的相关原子类去执行。

  • 禁止指令重排:首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

    • 就像一套栅栏分割前后的代码,阻止栅栏前后的没有数据依赖性的代码进行指令重排序,保证程序在一定程度上的有序性。

    • 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效,保证数据的可见性。

      img

    内存屏障有三种类型和一种伪类型:

    • lfence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。

    • sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见。

    • mfence,即全能屏障,具备ifence和sfence的能力。

    • Lock前缀:Lock不是一种内存屏障,但是它能完成类似全能型内存屏障的功能。

11、CAS

CAS的英文为Compare and Swap 翻译为比较并交换。它是一条CPU并发原语。它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。

11.1、原理

CAS的原理包括Unsafe类和自旋利用Unsafe提供的原子性操作方法

11.1.1、Unsafe

Unsafe是CAS的核心类,Java无法直接访问底层操作系统,而是通过本地(native)方法来访问。Unsafe类存在大量被native修饰的本地方法,它提供了硬件级别的原子操作。

通过调用unsafe.objectFieldOffset()方法获取变量值在内存中的偏移地址。

11.1.2、自旋

  • var1代表当前对象this

  • var2代表内存偏移值

  • var4是常量1

  • var5是用var1、var2找出的当前对象这个内存偏移量中真实的值

 public final int getAndAddInt(Object var1, long var2, int var4) {
  int var5;
  do {
  var5 = this.getIntVolatile(var1, var2);
  } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
 
  return var5;
 }

var5 = this.getIntVolatile(var1, var2);调用类Unsafe类的一个本地方法getIntVolatile获取当前对象这个内存偏移量的值是多少,获取到后赋值给var5。

接着又调用Unsafe类的compareAndSwapInt本地方法。var代表当前对象,var2代表内存偏移量。方法作用就是当前对象的内存偏移量位置取到的值是否和我们先前取到的var5值相同,如果相同值就变成了var5+var4,也就是值加1。方法成功返回true取否为flase,从而退出循环!返回var5以前的值。

如果当前对象这个内存偏移量的值与我们先前取到的var5不相同,则不加1返回false取否变为true,再次循环,直到比较成功而更新值,返回以前的值!这就是自旋。

11.2、缺点

  • 循环时间长开销很大:CAS 通常是配合无限循环一起使用的,我们可以看到 getAndAddInt 方法执行时,如果 CAS 失败,会一直进行尝试。如果 CAS 长时间一直不成功,可能会给 CPU 带来很大的开销。

  • 只能保证一个变量的原子操作:当对一个变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个变量操作时,CAS 目前无法直接保证操作的原子性。但是我们可以通过以下两种办法来解决:

    1. 使用互斥锁来保证原子性。

    2. 将多个变量封装成对象,通过 AtomicReference 来保证原子性。

  • ABA问题

11.2.1、ABA问题

11.2.1.1、产生原因

如线程1从内存X中取出A,这时候另一个线程2也从内存X中取出A,并且线程2进行了一些操作将内存X中的值变成了B,然后线程2又将内存X中的数据变成A,这时候线程1进行CAS操作发现内存X中仍然是A,然后线程1操作成功。虽然线程1的CAS操作成功,但是整个过程就是有问题的。比如链表的头在变化了两次后恢复了原值,但是不代表链表就没有变化。

11.2.1.2、解决方案
  • AtomicMarkableReference类描述的一个<Object, Boolean>的对,可以原子的修改Object或者Boolean的值,这种数据结构在一些缓存或者状态描述中比较有用。这种结构在单个或者同时修改Object/Boolean的时候能够有效的提高吞吐量。

  • AtomicStampedReference类维护带有整数“标志”的对象引用,可以用原子方式对其进行更新。对比AtomicMarkableReference 类的<Object, Boolean>,AtomicStampedReference 维护的是一种类似<Object, int>的数据结构,其实就是对对象(引用)的一个并发计数(标记版本戳stamp)。但是与AtomicInteger不同的是,此数据结构可以携带一个对象引用(Object),并且能够对此对象和计数同时进行原子操作。

12、锁的类型

名称描述
公平锁/非公平锁 公平锁是指多个线程按照申请锁的顺序来获取锁。 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。
可重入锁 又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
独享锁/共享锁 独享锁是指该锁一次只能被一个线程所持有。 共享锁是指该锁可被多个线程所持有。
互斥锁/读写锁 互斥锁在Java中的具体实现就是ReentrantLock(可重入锁)。 读写锁在Java中的具体实现就是ReadWriteLock(读写锁)。
乐观锁/悲观锁 悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。 乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。
分段锁 容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
偏向锁/轻量级锁/重量级锁 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。 轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。 重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
自旋锁 自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
posted @ 2022-02-17 21:08  是老胡啊  阅读(121)  评论(0)    收藏  举报