java并发常见问题

volatile

volatile 就是为了保证可见性和有序性,并不能保证原子性,多用于一个写线程,多个读线程的情况。

  • 将当前处理器缓存行的数据会写回到系统内存。
  • 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效,强制其它处理器重新从主存中加载。

    synchronize

    同步关键字,jdk内部机制,1.6优化了很多,可重入锁。

加锁对象

  • 对于同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前对象的Class对象。
  • 对于同步方法块,锁是Synchonized括号里配置的对象。

同步机制

JVM基于进入和退出Monitor来实现方法和代码块同步,每个对象都对应一个monitor,当monitor被持有后,他就被锁定,其它线程执行monitorenter指令时,就会被阻塞。

锁分类和升级

1.6中为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁和轻量级锁,这样锁的状态就有四种了,无锁状态,偏向锁,轻量级锁,重量级锁,而且锁只能升级不能降级。

偏向锁,当一个线程访问同步块并获取到锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要花费CAS操作来加锁解锁,,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。

  • 轻量级锁
    轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

    轻量级锁解锁:轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。

线程池

线程池好处

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗
  • 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控

线程池的流程

  1. 首先判断基本线程池满了没,如果满了,就进入下一个流程,没满就创建新线程执行任务。
  2. 判断队列是否满了,满了进入下一个流程,没满就将任务放入阻塞队列。
  3. 判断最大线程池满了没,满了就执行饱和策略,没满就创建新线程执行任务。

线程池就是和阻塞队列搭配使用,限制了线程的数量,将任务放到阻塞队列中,然后线程池中的线程从阻塞队列中取出任务执行。

concurrentHashMap

hash 算法是一种将任意内容输入转换成相同长度输出的加密方式,其输出被称为哈希值。

哈希表 根据设定的hash函数和处理冲突方法将一组关键字映射到一个有限的地址区间上,这个哈希值作为记录在表中的存储位置,这种表称为hash表或者散列,所得存储位置称为哈希地址或者散列地址。

  • hashMap不是线程安全的,主要是看内部源码实现,这个内部是数组+链表的拉链式存储结构,数组中的位置通过散列来确定,还有就是超过一定容量要进行再散列。
  • hashtable 效率很低下,因为他使用的是synchronize来保证线程安全的,在线程竞争激烈的情况下效率非常低下,当一个线程添加元素时,其它线程既不能添加也不能读取元素,所以效率非常低下。
  • concurrentHashMap,实现分段式加锁,数据进行分段,每一段数据配一把锁,这样就不会造成所有线程竞争一把锁,

segment数组,数组内容是类似于hashmap的存储结构,这里segment实现了锁,数组长度就相当于将数据分段了。

concurrencyLevel决定segment数组长度,segment数组长度必须是2的N次幂,为了方便通过按位与的算法确定元素在数组中的位置。concurrencyLevel最大为65535,这也就说明,segment数组的长度最大为65536。

对于segment就和hashmap差不多了,有负载因子,初始化concurrentHashMap的时候,会有一个初始化容量,知道segment的长度就可以确定每个segment中的hashentry的长度,这个也是2的N次幂,这个容量有个阈值,就是理论长度和负载因子成绩,超过这个就会进行再散列。

concurrentHashMap的再散列,极大避免了散列冲突。

get操作不需要加锁,get操作中要使用的共享变量都定义成volatile,保证每次读取到的都是最新的值,写操作是单线程的。

put操作共享变量时必须加锁,put的时候先判断是否需要对segment里的hashentry数组进行扩容,然后再插入数据到数组中。

concurrentHashMap的扩容是对单个segment进行扩容的。

原子操作

32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。

Java中的原子操作

java中可以通过锁和循环CAS的方式来实现原子操作。

自旋CAS就是不断循环直进行CAS操作直到成功为止。

CAS的问题

  • ABA问题,因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。atmoic包里提供了一个类AtomicStampedReference来解决ABA问题,这个类的cas会检查引用和标志是否都相等。
  • 循环时间长开销大,主要是如果自旋cas长时间不成功,那么就会给cpu带来非常大的开销。
  • 只能保证一个共享变量的原子操作,从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

ConcurrentLinkedQueue非阻塞队列线程安全

基于链节点的无界线程安全队列,FIFO。采用非阻塞的方式实现了这个线程安全队列。

head节点和tail节点,如果tail节点的next节点为空,那么tail节点不变,入队节点设置成tail节点的next节点,如果tail节点next非空,那么入队节点就紧跟着,然后tail节点设置成入队节点。

  • 入队过程
    入队过程主要是干了两件事,一个是确定尾节点,另一个是使用CAS算法将入队节点设置成尾节点的next,不成功就会不断重试,循环CAS。

尾节点不是设置成入队节点,为了减少cas循环次数,每次都更新tail节点就会在每次入队节点时都会使用cas去更新tail,这样会降低效率,所以设置了hops来控制tail和尾节点之间的距离。

  • 出队过程

出队更新的是head节点,但不是每次出队都会更新head节点,也和tail一样使用hops来控制的,提高出队效率。

线程安全阻塞队列

阻塞队列就是加了两个附加操作,队列满时加入任务就会阻塞,队列空时取元素会阻塞。

  • ArrayBlockingQueue 数组实现的有界阻塞队列,FIFO
  • LinkedBlockingQueue 有界,FIFO
  • PriorityBlockingQueue 无界,
  • DelayQueue 支持延时获取元素的无界阻塞队列,应用场景通常是缓存和定时任务。
  • LinkedBlockingDeque,双向阻塞队列

Fork/Join框架

Fork/Join框架就是将一个大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。

  • 工作窃取算法(work-stealing)
    某个线程从其他队列里窃取任务来执行。

比如一个大任务,分割成若干个子任务,为了减少线程间的竞争,把这些子任务分配到不同队列中,然后每个队列创建一个线程来处理队列中的任务,当处理完自己的任务时,这个线程可以去其它队列中窃取任务执行,而且为了减少线程的竞争,这些队列通常是双端队列,窃取任务的线程和正常的线程分别从两端取任务执行。

Fork/Join框架步骤

  1. fork类把大任务分割成小任务
  2. 执行任务合并结果,分割的子任务放到双端队列里,启动几个线程去到队列里面取任务执行。子任务执行完的结果都统一放到一个队列里面,然后启动一个线程去合并这些结果。
    下面是简单的例子
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    class Forkjoin extends RecursiveTask{
    private int start;
    private int end;
    private static final int THREAD = 2;
    public Forkjoin(int start, int end) {
    this.start = start;
    this.end = end;
    }

    @Override
    protected Object compute() {
    int sum = 0;
    boolean canCompute = (end - start) <= THREAD;
    if (canCompute){
    for (int i = start; i <= end; i++)
    sum += i;
    }else {
    int middle = (end + start) / 2;
    Forkjoin task1 = new Forkjoin(start, middle);
    Forkjoin task2 = new Forkjoin(middle + 1, end);
    task1.fork();
    task2.fork();
    int res1 = (int) task1.join();
    int res2 = (int) task2.join();
    sum = res1 + res2;
    }
    return sum;
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
    ForkJoinPool forkJoinPool = new ForkJoinPool();
    Forkjoin forkjoin = new Forkjoin(1,1000);
    Future future = forkJoinPool.submit(forkjoin);
    System.out.println(future.get());
    }
    }

调用fork方法时,程序调用pushTask方法异步的执行这个任务,然后立即返回结果,先将任务存到queue里,然后再唤醒或者创建一个工作线程执行这个任务。

join的话就是阻塞当前线程并等待获取结果。

Copy-Ob-Write

写时复制的容器,当我们往一个容器添加元素的时候,不直接往当前元素添加,而是先将当前容器进行copy,然后将元素添加到新的copy里面,最后将原来的容器的引用指向新的容器。读写分离,读和写不同的容器,好处是进行并发读不需要加锁。

CopyOnWrite并发容器用于读多写少的并发场景。

谈一下并发为什么会出问题呢,首先说一下原子操作,并发情况下,调度算法会分给每个操作一个时间段,这就会导致不是原子操作的情况下,操作会被中断然后被另一个线程占用,这就会出现问题,所以要保证原子操作,一个是使用锁或者同步关键字,最后就是concurrent并发包中的原子类,CAS操作,然后就是由于Java的内存模型,各线程有自己的工作内存,然后还有一个主内存,线程直接交互的是自己的工作内存,这就导致在并发情况下的缓存一致性问题,解决这个问题有两种办法,一种就是加锁,另一个就是缓存一致性协议,比如volatile修饰的变量,每次这个变量被写入的时候,会被立即刷新到主内存,其它线程对这个变量的缓存都会失效,然后会从主内存中从新读取,这样是很轻量级的同步,这里就引入了happens before原则,volatile就满足Java先天的满足这个原则的,这个说明的是,前面操作的结果对后一个操作的结果可见,这个只是说明的前一个操作结果对后一个的影响,没有说明时间上发生的顺序,时间上发生的前后并不一定满足这个原则,所以加上volatile就会保证这个可见性。还有一个问题就是因为编译器和系统环境的优化问题,会存在几个重排序,一个是编译器级别的重排序,一个是系统给的重排序,这个在单线程中不会导致什么问题,但是在并发情况下还会出现问题,解决这个问题目前最常见的是volatile关键字,这个关键字底层的实现就是编译器加了一个内存屏障,这个内存屏障的作用就是保证该条操作的前后的顺序不会改变,但是不保证前面或者后面的顺序,就是他会保证程序顺序情况下,在他前面发生的还是在他前面发生,后面的也是,但是不保证前后的单独部分的重排序问题。所以其实volatile解决了两个问题,一个是缓存一致性问题,一个是重排序的问题,其中缓存一致性问题只是保证了当前变量的写入会被立即刷新到主内存,而且其它未发生的读取线程的对于该变量的缓存会失效,重新读取。但是你要是想看到这个结果,实际上,就必须要求那个变量的写入在读的操作之前。那些文章中说的happens before原则不一定是前后发生的,这个是因为重排序问题导致的。说的只是同一个线程内部肯定满足happens before原子,但是又因为重排序的问题才会说这个的,其实和那个volatile没关系。并发最后就是让在并发情况下的发生的结果和顺序情况下的一样。这样就显得很明了了。

文章目录
  1. 1. volatile
  2. 2. synchronize
    1. 2.1. 线程池
    2. 2.2. concurrentHashMap
    3. 2.3. 原子操作
      1. 2.3.1. Java中的原子操作
    4. 2.4. ConcurrentLinkedQueue非阻塞队列线程安全
    5. 2.5. 线程安全阻塞队列
    6. 2.6. Fork/Join框架
    7. 2.7. Copy-Ob-Write
,