Java并发编程总结

进程线程

进程作为资源分配的基本单位,线程作为资源调度的基本单位

synchronized

synchronized实现锁的方式是在Java的对象头的锁标记位进行标记锁的类别。

synchronized一共有四种锁状态,分别是无锁状态,偏向锁,轻量级锁,重量级锁。

  • 偏向锁

    当只有一个线程竞争的时候一般都是偏向锁,这个锁会在锁标记位记录线程ID,同样线程的栈帧也会记录对象的锁标记位。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。

  • 轻量级锁

    轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。

    轻量级锁的获取主要由两种情况:

    当关闭偏向锁功能时;

    • 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
    • 一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是

    锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

    在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

    长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。

    如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

  • 重量级锁

    重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。
    重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资。

volatile

volatile可以保证变量的可见性以及有序性,并不能保证原子性,所以单纯只有volatile并不能保证线程安全。

  • volatile实现可见性的方式是:当一个线程改变了这个变量之后,会立即将这个改变的结果从线程的私有内存刷新到共享内存里面去,其他线程会在要使用这个变量的时候向共享内存刷新一下再使用。这个机制保证了多个线程对同一个变量的时候保证这个变量的可见性。

需要注意的是,volatile不保证原子性,所以在进行依赖自身的计算的时候用volatile是没有用的,例如x++,因为这一行代码包含了三行指令,读取-计算-修改,volatile不能保证这三个指令是原子性的,还是会导致线程安全问题。

有了volatile修饰的变量在汇编层面会多一条Lock前缀的指令,这个指令会使得变量发生修改后立即将数据写到系统内存也就是这个共享内存,而缓存一致性协议会阻止两个以上线程来修改这块内存的值,并且保证私有线程的变量更新到内存这一修改的原子性。

  • volatile还可以防止指令重排序保证有序性。因为在执行过程中处理器为了提高执行效率,会优化指令的顺序。假如一个线程先加载对象,再修改标记,此时另一个线程在等待这个标记改变,一旦改变就会停止阻塞去拿这个对象。如果因为指令重排序导致了第一个线程在加载好对象前就修改了标记变量,就会导致另一个线程出现错误。下面解析一下如何防止指令重排序。

在这里插入图片描述

  • 在每个volatile写操作的前面插入一个StoreStore屏障,后面插入一个StoreLoad屏障,保证volatile写与之前的写操作指令不会重排序,写完数据之后立即执行flush处理器缓存操作将所有写操作刷到内存,对所有处理器可见。
  • 在每个volatilie读操作的前面插入一LoadLoad屏障,保证在该变量读操作时,如果其他处理修改过,必须从其他处理器高速缓存(或者主内存)加载到自己本地高速缓存,保证读取到的值是最新的。然后在该变量读操作后面插入一个LoadStore屏障,禁止volatile读操作与后面任意读写操作重排序。

CAS

CAS的全称为compare and swap,是个原子性的操作;
CAS有三个值:期望值,内存值,修改值。如果期望值和内存值不一致,要么放弃要么重试,如果期望值和内存值相同,就把内存值修改为修改值;
但是CAS会带来ABA问题,如果1线程读到的期望值是10,此时2线程将内存值改为了100,然后3线程又改成了10,这个时候1线程是感觉不到这个期望值和内存值是有什么不一样的,这就是ABA问题。解决方法就是java提供了一个类,在值的基础上加个值的版本,在对比的时候就比值和版本是否一样。

Java的内存模型

  • Java内存模型定义了:Java线程对内存数据进行交互的规范。线程之间的「共享变量」存储在「主内存」中,每个线程都有自己私有的「本地内存」,「本地内存」存储了该线程以读/写共享变量的副本。本地内存是Java内存模型的抽象概念,并不是真实存在的。

在这里插入图片描述

  • Java内存模型规定了:线程对变量的所有操作都必须在「本地内存」进行,「不能直接读写主内存」的变量。而「变量如何从主内存到本地内存,以及变量如何从本地内存到主内存」是由8种操作完成的:分别是read/load/use/assign/store/write/lock/unlock操作

在这里插入图片描述

happen-before规则

happen-before实际上也是一套「规则」。Java内存模型定义了这套规则,目的是为了阐述「操作之间」的内存「可见性」。因为CPU为了优化执行效率会重排序某些指令,而happen-before规则就是阐述「前面一个操作的结果对后续操作必须是可见的」。规则总共有6条

  1. 程序顺序规则:单个线程中的每个操作,happens-before于改线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁unlock操作,一定是happens-before于对这个锁的加锁lock操作。
  3. volatile变量规则:对一个volatile修饰的变量的写操作,happens-before于任意后续对这个volatile变量的读操作。
  4. 传递性:如果a happens-before b,且b happens-before c,那么a happens-before c。
  5. start()规则:如果线程a执行操作ThreadB.start(),那么a线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程a执行操作ThreadB.join()并成功返回,那么线B中任意操作happens-before于线程a从ThreadB.join()操作成功返回。

队列同步器(AQS)

AQS是一个Java线程同步的框架,是JDK中很多锁工具的核心实现框架;

它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作,同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态;

同步器是实现锁(也可以是任意同步组件)的关键,锁是面向使用者的,它定义了使用者与锁交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;同步器面向的是锁的实现者, 它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。锁和同 步器很好地隔离了使用者和实现者所需关注的领域。

当一个线程进来,state为0时,表示没有线程占用,此时可以直接获得资源;state不为0,表示有资源占用,那么就需要自旋等待,即进入AQS中自旋排队等待;AQS中是由一个一个Node组成的。当一个线程进入AQS中等待的时候,发生了中断。

重入锁(ReentrantLock)

ReentrantLock是Lock接口的一个实现类,他是一个可重入锁,核心实现就是基于AQS的。他加锁解锁的过程如下:

  • CAS尝试获取锁,获取成功则可以执行同步代码
  • CAS获取失败,则调用acquire方法,acquire方法实际上就是AQS的模板方法,acquire首先会调用子类的tryAcquire方法
  • tryAcquire方法实际上会判断当前的state是否等于0,等于0说明没有线程持有锁,则又尝试CAS直接获取锁
  • 如果CAS获取成功,则可以执行同步代码
  • 如果CAS获取失败,那判断当前线程是否就持有锁,如果是持有的锁,那更新state的值,获取得到锁(这里其实就是处理可重入的逻辑)
  • CAS失败&&非重入的情况,则回到tryAcquire方法执行「入队列」的操作
  • 将节点入队列之后,会判断「前驱节点」是不是头节点,如果是头结点又会用CAS尝试获取锁
  • 如果是「前驱节点」是头节点并获取得到锁,则把当前节点设置为头结点,并且将前驱节点置空(实际上就是原有的头节点已经释放锁了)
  • 没获取得到锁,则判断前驱节点的状态是否为SIGNAL,如果不是,则找到合法的前驱节点,并使用CAS将状态设置为SIGNAL
  • 最后调用park将当前线程挂起

读写锁

在Java并发包中提供的是ReentrantReadWriteLock

读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读 线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。

锁降级: 锁降级指的是写锁降级成为读锁。

ThreadPoolExecutor

ThreadPoolExecutor在构造的时候有几个重要的参数,分别是:corePoolSize(核心线程数量)、maximumPoolSize(最大线程数量)、keepAliveTime(线程空余时间)、workQueue(阻塞队列)、handler(任务拒绝策略)

  • 首先会判断运行线程数是否小于corePoolSize,如果小于,则直接创建新的线程执行任务
  • 如果大于corePoolSize,判断workQueue阻塞队列是否已满,如果还没满,则将任务放到阻塞队列中
  • 如果workQueue阻塞队列已经满了,则判断当前线程数是否大于maximumPoolSize,如果没大于则创建新的线程执行任务
  • 如果大于maximumPoolSize,则执行任务拒绝策略(具体就是你自己实现的handler)
  • 这里有个点需要注意下,就是workQueue阻塞队列满了,但当前线程数小于maximumPoolSize,这时候会创建新的线程执行任务

假设运行应用的机器CPU核心数是N,cpu密集型的可以先给到N+1,io密集型的可以给到2N去试试

ThreadLocal

作用:ThreadLocal可以让每个不同的线程thread,都拥有一个相同的变量,而这个变量每个线程都不一样。

public class ThreadLocalTest {

    static ThreadLocal<String> localVar = new ThreadLocal<>();

    static void print(String str) {
        //打印当前线程中本地内存中本地变量的值
        System.out.println(str + " :" + localVar.get());
        //清除本地内存中的本地变量
        localVar.remove();
    }

    public static void main(String[] args) {
        Thread t1  = new Thread(new Runnable() {
            @Override
            public void run() {
                //设置线程1中本地变量的值
                localVar.set("localVar1");
                //调用打印方法
                print("thread1");
                //打印本地变量
                System.out.println("after remove : " + localVar.get());
            }
        });

        Thread t2  = new Thread(new Runnable() {
            @Override
            public void run() {
                //设置线程1中本地变量的值
                localVar.set("localVar2");
                //调用打印方法
                print("thread2");
                //打印本地变量
                System.out.println("after remove : " + localVar.get());
            }
        });

        t1.start();
        t2.start();
    }
}

实现方式:一个线程Thread,会维护一个ThreadLocalMap对象,这个对象里面有很多<Key, Value>的Entry对象,其中的Key就是ThreadLocal对象,一个ThreadLocalMap对象可以包含很多很多ThreadLocal对象,每个ThreadLocal对象对应一个value,这样就实现了ThreadLocal存一个线程私有的value。而不同的线程是可以有相同的ThreadLocal对象的,这样不就相当于在不同线程里面有相同的私有变量,还互不干扰。

内存泄露问题:ThreadLocal的内存泄漏问题是因为线程的栈帧里有个ThreadLocal的引用(强引用),还有个Thread引用,而Thread对象在堆中,Thread对象有个ThreadLocalMap对象,他的entry的key是个ThreadLocal对象的引用(弱引用),当ThreadLocal引用置为null的时候,因为还有Thread -> ThreadLocalMap -> Entry Key 这么一条链路引用着ThreadLocal对象,以至于不会被回收,导致内存的泄露。

CountDownLatch

一个或者一组线程必须在其他线程完成之后才能开始执行。

CountDownLatch是基于AQS实现的。我们在创建一个CountDownLatch对象的时候,要传入一个数字,这个数字实际上最终会写到AQS的state变量上面,而调用countDown方法的时候也就是将state-1。执行await()方法其实就是判断state是否为0,当state不为0时,就将线程加入到队列中阻塞掉,只有头节点一直自旋等待state为0。当state为0时,头结点就会唤醒全部线程。

CylicBarrir

当线程达到某个状态后会暂停下来等待其他线程。

CylicBarrir 是基于ReentrantLock+Condition 实现线程等待与唤醒的。在构建CylicBarrir对象的时候传入的数值会赋给内部的count变量,也会赋给parties变量(可以复用的原因),每次调用await() 就会count-1 (上ReentrantLock来保证线程安全)。如果count不为0,则添加进Condition队列,如果count为0,就唤醒全部线程。

  • 34
    点赞
  • 58
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值