并发容器

JDK 中提供了哪些并发容器?

JDK 提供的这些容器大部分在 java.util.concurrent 包中。

  1. ConcurrentHashMap:线程安全的 HashMap;
  2. CopyOnWriteArrayList:线程安全的 List,在读多写少的场合性能非常好,远远好于 Vector;
  3. ConcurrentLinkedQueue:高效的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列;
  4. BlockingQueue:这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道;
  5. ConcurrentSkipListMap:跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。

    java中常用容器

    常见容器主要包括 Collection 和 Map 两种,Collection 存储着对象的集合,而 Map 存储着键值对(两个对象)的映射表
    并发问题 - 图1
    Collection
  • Set
  1. TreeSet:基于红黑树实现,支持有序性操作,例如:根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN)。
  2. HashSet:基于HashMap实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的。
  3. LinkedHashSet:具有 HashSet 的查找效率,且内部使用双向链表维护元素的插入顺序。
  • List
  1. ArrayList:基于动态数组实现,支持随机访问。
  2. Vector:和 ArrayList 类似,但它是线程安全的(这里需要注意:vector的单个操作时原子性的,也就是线程安全的。但是如果两个原子操作复合而来,这个组合的方法是非线程安全的,需要使用锁来保证线程安全,具体可以看这篇文章:Vection的非线程安全操作
  3. LinkedList:基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。
  • Queue
  1. LinkedList:可以用它来实现双向队列。
  2. PriorityQueue:基于堆结构实现,可以用它来实现优先队列。

    Map
  3. TreeMap:基于红黑树实现。

  4. HashMap:基于哈希表实现。
  5. HashTable:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用 ConcurrentHashMap 来支持线程安全,并且 ConcurrentHashMap 的效率会更高,因为 ConcurrentHashMap 引入了分段锁。
  6. LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。

    HashMap和HashTable有什么区别?其底层实现是什么?

区别 :

  1. HashMap方法没有synchronized修饰,线程非安全,HashTable线程安全;
  2. HashMap允许key和value为null,而HashTable不允许

底层实现:数组+链表实现,jdk8开始链表高度到8、数组长度超过64,链表转变为红黑树,元素以内部类Node节点存在

  1. 计算key的hash值,二次hash然后对数组长度取模,对应到数组下标,
  2. 如果没有产生hash冲突(下标位置没有元素),则直接创建Node存入数组,
  3. 如果产生hash冲突,先进行equal比较,相同则取代该元素,不同,则判断链表高度插入链表,链表高度达到8,并且数组长度到64则转变为红黑树,长度低于6则将红黑树转回链表
  4. key为null,存在下标0的位置

    HashSet 的实现原理?

    HashSet 的实现是依赖于 HashMap 的,HashSet 的值都是存储在 HashMap 中的。在 HashSet 的构造法中会初始化一个 HashMap 对象,HashSet 不允许值重复。因此,HashSet 的值是作为 HashMap 的 key 存储在 HashMap 中的,当存储的值已经存在时返回 false。

    LinkedHashMap 的实现原理?

    LinkedHashMap 也是基于 HashMap 实现的,不同的是它定义了一个 Entry header,这个 header 不是放在 Table 里,它是额外独立出来的。LinkedHashMap 通过继承 hashMap 中的 Entry,并添加两个属性 Entry before,after 和 header 结合起来组成一个双向链表,来实现按插入顺序或访问顺序排序。
    LinkedHashMap 定义了排序模式 accessOrder,该属性为 boolean 型变量,对于访问顺序,为 true;对于插入顺序,则为 false。一般情况下,不必指定排序模式,其迭代顺序即为默认为插入顺序。

    HashMap1.7到1.8发生了什么变化(底层)?

  5. 1.7中底层是数组+链表,1.8中底层是数组+链表+红黑树,加红黑树的目的是提高HashMap插入和查询整体效率

  6. 1.7中链表插入使用的是头插法,1.8中链表插入使用的是尾插法,因为1.8中插入key和value时需要判断链表元素个数,所以需要遍历链表统计链表元素个数,所以正好就直接使用尾插法
  7. 1.7中哈希算法比较复杂,存在各种右移与异或运算,1.8中进行了简化,因为复杂的哈希算法的目的就是提高散列性,来提供HashMap的整体效率,而1.8中新增了红黑树,所以可以适当的简化哈希算法,节省CPU资源

说一下HashMap的Put方法

先说HashMap的Put方法的大体流程:

  1. 根据Key通过哈希算法与与运算得出数组下标
  2. 如果数组下标位置元素为空,则将key和value封装为Entry对象(JDK1.7中是Entry对象,JDK1.8中是Node对象)并放入该位置
  3. 如果数组下标位置元素不为空,则要分情况讨论

    1. 如果是JDK1.7,则先判断是否需要扩容,如果要扩容就进行扩容,如果不用扩容就生成Entry对象,并使用头插法添加到当前位置的链表中
    2. 如果是JDK1.8,则会先判断当前位置上的Node的类型,看是红黑树Node,还是链表Node
      1. 如果是红黑树Node,则将key和value封装为一个红黑树节点并添加到红黑树中去,在这个过程中会判断红黑树中是否存在当前key,如果存在则更新value
      2. 如果此位置上的Node对象是链表节点,则将key和value封装为一个链表Node并通过尾插法插入到链表的最后位置去,因为是尾插法,所以需要遍历链表,在遍历链表的过程中会判断是否存在当前key,如果存在则更新value,当遍历完链表后,将新链表Node插入到链表中,插入到链表后,会看当前链表的节点个数,如果大于等于8,那么则会将该链表转成红黑树
      3. 将key和value封装为Node插入到链表或红黑树中后,再判断是否需要进行扩容,如果需要就扩容,如果不需要就结束PUT方法

        HashMap 的 resize 方法的执行过程?

        有两种情况会调用 resize 方法:
  4. 第一次调用 HashMap 的 put 方法时,会调用 resize 方法对 table 数组进行初始化,如果不传入指定值,默认大小为 16。

  5. 扩容时会调用 resize,即 size > threshold 时,table 数组大小翻倍。

每次扩容之后容量都是翻倍。扩容后要将原数组中的所有元素找到在新数组中合适的位置。
当我们把 table[i] 位置的所有 Node 迁移到 newtab 中去的时候:这里面的 node 要么在 newtab 的 i 位置(不变),要么在 newtab 的 i + n 位置。也就是我们可以这样处理:把 table[i] 这个桶中的 node 拆分为两个链表 l1 和 l2:如果 hash & n 0,那么当前这个 node 被连接到 l1 链表;否则连接到 l2 链表。这样下来,当遍历完 table[i] 处的所有 node 的时候,我们得到两个链表 l1 和 l2,这时我们令 newtab[i] = l1,newtab[i + n] = l2,这就完成了 table[i] 位置所有 node 的迁移(rehash),这也是 HashMap 中容量一定的是 2 的整数次幂带来的方便之处。

HashMap的扩容机制原理

1.7版本

  1. 先生成新数组
  2. 遍历老数组中的每个位置上的链表上的每个元素
  3. 取每个元素的key,并基于新数组长度,计算出每个元素在新数组中的下标
  4. 将元素添加到新数组中去
  5. 所有元素转移完了之后,将新数组赋值给HashMap对象的table属性

1.8版本

  1. 先生成新数组
  2. 遍历老数组中的每个位置上的链表或红黑树
  3. 如果是链表,则直接将链表中的每个元素重新计算下标,并添加到新数组中去
  4. 如果是红黑树,则先遍历红黑树,先计算出红黑树中每个元素对应在新数组中的下标位置
    1. 统计每个下标位置的元素个数
    2. 如果该位置下的元素个数超过了8,则生成一个新的红黑树,并将根节点的添加到新数组的对应位置
    3. 如果该位置下的元素个数没有超过8,那么则生成一个链表,并将链表的头节点添加到新数组的对应位置
  5. 所有元素转移完了之后,将新数组赋值给HashMap对象的table属性

    ConcurrentHashMap 的实现原理是什么?

    JDK 7:中 ConcurrentHashMap 采用了数组 + Segment + 分段锁的方式实现。
    JDK 8:中 ConcurrentHashMap 参考了 JDK 8 HashMap 的实现,采用了数组 + 链表 + 红黑树的实现方式来设计,内部大量采用 CAS 操作。
    ConcurrentHashMap 采用了非常精妙的”分段锁”策略,ConcurrentHashMap 的主干是个 Segment 数组。
    final Segment[] segments;
    Segment 继承了 ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。在 ConcurrentHashMap,一个 Segment 就是一个子哈希表,Segment 里维护了一个 HashEntry 数组,并发环境下,对于不同 Segment 的数据进行操作是不用考虑锁竞争的。就按默认的 ConcurrentLevel 为 16 来讲,理论上就允许 16 个线程并发执行。
    所以,对于同一个 Segment 的操作才需考虑线程同步,不同的 Segment 则无需考虑。Segment 类似于 HashMap,一个 Segment 维护着一个HashEntry 数组:
    transient volatile HashEntry[] table;
    HashEntry 是目前我们提到的最小的逻辑处理单元了。一个 ConcurrentHashMap 维护一个 Segment 数组,一个 Segment 维护一个 HashEntry 数组。因此,ConcurrentHashMap 定位一个元素的过程需要进行两次 Hash 操作。第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表的头部。

    ConcurrentHashMap的扩容机制

    1.7版本

    1. 1.7版本的ConcurrentHashMap是基于Segment分段实现的
    2. 每个Segment相对于一个小型的HashMap
    3. 每个Segment内部会进行扩容,和HashMap的扩容逻辑类似
    4. 先生成新的数组,然后转移元素到新数组中
    5. 扩容的判断也是每个Segment内部单独判断的,判断是否超过阈值

1.8版本

  1. 1.8版本的ConcurrentHashMap不再基于Segment实现
  2. 当某个线程进行put时,如果发现ConcurrentHashMap正在进行扩容那么该线程一起进行扩容
  3. 如果某个线程put时,发现没有正在进行扩容,则将key-value添加到ConcurrentHashMap中,然后判断是否超过阈值,超过了则进行扩容
  4. ConcurrentHashMap是支持多个线程同时扩容的
  5. 扩容之前也先生成一个新的数组
  6. 在转移元素时,先将原数组分组,将每组分给不同的线程来进行元素的转移,每个线程负责一组或多组的元素转移工作

    谈谈对 CopyOnWriteArrayList 的理解?

    在很多应用场景中,读操作可能会远远大于写操作。由于读操作根本不会修改原有的数据,因此对于每次读取都进行加锁其实是一种资源浪费。我们应该允许多个线程同时访问 List 的内部数据,毕竟读取操作是安全的。
    CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,我们并不需要修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。
    从 CopyOnWriteArrayList 的名字就能看出 CopyOnWriteArrayList 是满足 CopyOnWrite 的 ArrayList,所谓 CopyOnWrite 也就是说:在计算机,如果你想要对一块内存进行修改时,我们不在原有内存块中进行写操作,而是将内存拷贝一份,在新的内存中进行写操作,写完之后,就将指向原来内存指针指向新的内存,原来的内存就可以被回收掉了。
    CopyOnWriteArrayList 读取操作没有任何同步控制和锁操作,理由就是内部数组 array 不会发生修改,只会被另外一个 array 替换,因此可以保证数据安全。
    CopyOnWriteArrayList 写入操作 add() 方法在添加集合的时候加了锁,保证了同步,避免了多线程写的时候会 copy 出多个副本出来。

    CopyOnWriteArrayList的底层原理是怎样的

  7. 首先CopyOnWriteArrayList内部也是用数组来实现的,在向CopyOnWriteArrayList添加元素时,会复制一个新的数组,写操作在新数组上进行,读操作在原数组上进行

  8. 并且,写操作会加锁,防止出现并发写入丢失数据的问题
  9. 写操作结束之后会把原数组指向新数组
  10. CopyOnWriteArrayList允许在写操作时来读取数据,大大提高了读的性能,因此适合读多写少的应用场景,但是CopyOnWriteArrayList会比较占内存,同时可能读到的数据不是实时最新的数据,所以不适合实时性要求很高的场景

    谈谈对 BlockingQueue 的理解?分别有哪些实现类?

    阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
    BlockingQueue 是一个接口,继承自 Queue,所以其实现类也可以作为 Queue 的实现来使用,而 Queue 又继承自 Collection 接口。下面是 BlockingQueue 的相关实现类:

谈谈对 ConcurrentSkipListMap 的理解?

对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。
但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 。跳表的本质是同时维护了多个链表,并且链表是分层的。

并发、并行、串行之间的区别

  1. 串行在时间上不可能发生重叠,前一个任务没搞定,下一个任务就只能等着
  2. 并行在时间上是重叠的,两个任务在同一时刻互不干扰的同时执行。
  3. 并发允许两个任务彼此干扰。统一时间点、只有一个任务运行,交替执行

并发的三大特性

原子性
原子性是指在一个操作中cpu不可以在中途暂停然后再调度,即不被中断操作,要不全部执行完成,要不都不执行。就好比转账,从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。2个操作必须全部完成。

  1. private long count = 0;
  2. public void calc() {
  3. count++;
  4. }
  • 1:将 count 从主存读到工作内存中的副本中
  • 2:+1的运算
  • 3:将结果写入工作内存
  • 4:将工作内存的值刷回主存(什么时候刷入由操作系统决定,不确定的)

那程序中原子性指的是最小的操作单元,比如自增操作,它本身其实并不是原子性操作,分了3步的,包括读取变量的原始值、进行加1操作、写入工作内存。所以在多线程中,有可能一个线程还没自增完,可能才执行到第二部,另一个线程就已经读取了值,导致结果错误。那如果我们能保证自增操作是一个原子性的操作,那么就能保证其他线程读取到的一定是自增后的数据。

关键字:synchronized

可见性
当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

若两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。

  1. //线程1
  2. boolean stop = false;
  3. while(!stop){
  4. doSomething();
  5. }
  6. //线程2
  7. stop = true;

如果线程2改变了stop的值,线程1一定会停止吗?不一定。当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

关键字:volatile、synchronized、final

有序性
虚拟机在进行代码编译时,对于那些改变顺序之后不会对最终结果造成影响的代码,虚拟机不一定会按照我们写的代码的顺序来执行,有可能将他们重排序。实际上,对于有些代码进行重排序之后,虽然对变量的值没有造成影响,但有可能会出现线程安全问题。

  1. int a = 0;
  2. bool flag = false;
  3. public void write() {
  4. a = 2; //1
  5. flag = true; //2
  6. }
  7. public void multiply() {
  8. if (flag) { //3
  9. int ret = a * a;//4
  10. }
  11. }

write方法里的1和2做了重排序,线程1先对flag赋值为true,随后执行到线程2,ret直接计算出结果,再到线程1,这时候a才赋值为2,很明显迟了一步

关键字:volatile、synchronized

volatile本身就包含了禁止指令重排序的语义,而synchronized关键字是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则明确的。

synchronized关键字同时满足以上三种特性,但是volatile关键字不满足原子性。

在某些情况下,volatile的同步机制的性能确实要优于锁(使用synchronized关键字或java.util.concurrent包里面的锁),因为volatile的总开销要比锁低。

我们判断使用volatile还是加锁的唯一依据就是volatile的语义能否满足使用的场景(原子性)

线程池

线程池的底层工作原理

线程池内部是通过队列+线程实现的,当我们利用线程池执行任务时:

  1. 如果此时线程池中的线程数量小于corePoolSize,即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务。
  2. 如果此时线程池中的线程数量等于corePoolSize,但是缓冲队列workQueue未满,那么任务被放入缓冲队列。
  3. 如果此时线程池中的线程数量大于等于corePoolSize,缓冲队列workQueue满,并且线程池中的数量小于maximumPoolSize,建新的线程来处理被添加的任务。
  4. 如果此时线程池中的线程数量大于corePoolSize,缓冲队列workQueue满,并且线程池中的数量等于maximumPoolSize,那么通过 handler所指定的策略来处理此任务。
  5. 当线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止。这样,线程池可以动态的调整池中的线程数

    为什么用线程池?解释下线程池参数?

1、降低资源消耗;提高线程利用率,降低创建和销毁线程的消耗。

2、提高响应速度;任务来了,直接有线程可用可执行,而不是先创建线程,再执行。

3、提高线程的可管理性;线程是稀缺资源,使用线程池可以统一分配调优监控。

  • corePoolSize 代表核心线程数,也就是正常情况下创建工作的线程数,这些线程创建后并不会消除,而是一种常驻线程
  • maxinumPoolSize 代表的是最大线程数,它与核心线程数相对应,表示最大允许被创建的线程数,比如当前任务较多,将核心线程数都用完了,还无法满足需求时,此时就会创建新的线程,但是线程池内线程总数不会超过最大线程数 如果使用了无界的任务队列这个参数就没什么效果。

  • keepAliveTimeunit 表示超出核心线程数之外的线程的空闲存活时间,也就是核心线程不会消除,但是超出核心线程数的部分线程如果空闲一定的时间则会被消除,我们可以通过 setKeepAliveTime 来设置空闲时间

  • workQueue 用来存放待执行的任务,假设我们现在核心线程都已被使用,还有任务进来则全部放入队列,直到整个队列被放满但任务还再持续进入则会开始创建新的线程
  • 可以选择以下几个阻塞队列:

    1)、 ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。 有界的数组可以防止资源耗尽问题。当线程池中线程数量达到corePoolSize后,再有新任务进来,则会将任务放入该队列的队尾,等待被调度。如果队列已经是满的,则创建一个新线程,如果线程数量已经达到maxPoolSize,则会执行拒绝策略。 2)、LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按 FIFO 排序元素,吞吐量通常要高于 ArrayBlockingQueue。静态工厂方法 Executors.newFixedThreadPool() 使用了这个队列。 基于链表的无界阻塞队列(其实最大容量为Interger.MAX)由于该队列的近似无界性,当线程池中线程数量达到corePoolSize后,再有新任务进来,会一直存入该队列,而不会去创建新线程直到maxPoolSize,因此使用该工作队列时,参数maxPoolSize其实是不起作用的。 3)、SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于 LinkedBlockingQueue,静态工厂方法 Executors.newCachedThreadPool 使用了这个队列。 4)、 PriorityBlockingQueue:一个具有优先级的无限阻塞队列。 优先级通过参数Comparator实现。

  • ThreadFactory 实际上是一个线程工厂,用来生产线程执行任务。我们可以选择使用默认的创建工厂,产生的线程都在同一个组内,拥有相同的优先级,且都不是守护线程。当然我们也可以选择自定义线程工厂,一般我们会根据业务来制定不同的线程工厂

  • Handler 任务拒绝策略,有两种情况,第一种是当我们调用shutdown 等方法关闭线程池后,这时候即使线程池内部还有没执行完的任务正在执行,但是由于线程池已经关闭,我们再继续想线程池提交任务就会遭到拒绝。另一种情况就是当达到最大线程数,线程池已经没有能力继续处理新提交的任务时,这是也就拒绝
    1. AbortPolicy:直接抛出异常。
    2. CallerRunsPolicy:只用调用者所在线程来运行任务。
    3. DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
    4. DiscardPolicy:不处理,丢弃掉。

线程池中的的线程数一般怎么设置?需要考虑哪些问题?

  • 1. 线程池中线程执行任务的性质:

计算密集型的任务比较占 cpu,所以一般线程数设置的大小 等于或者略微大于 cpu 的核数;但 IO 型任务主要时间消耗在 IO 等待上,cpu 压力并不大,所以线程数一般设置较大。

  • 2. cpu 使用率:

当线程数设置较大时,会有如下几个问题:第一,线程的初始化,切换,销毁等操作会消耗不小的 cpu 资源,使得 cpu 利用率一直维持在较高水平。第二,线程数较大时,任务会短时间迅速执行,任务的集中执行也会给 cpu 造成较大的压力。第三, 任务的集中支持,会让 cpu 的使用率呈现锯齿状,即短时间内 cpu 飙高,然后迅速下降至闲置状态,cpu 使用的不合理,应该减小线程数,让任务在队列等待,使得 cpu 的使用率应该持续稳定在一个合理,平均的数值范围。所以 cpu 在够用时,不宜过大,不是越大越好。可以通过上线后,观察机器的 cpu 使用率和 cpu 负载两个参数来判断线程数是否合理。

  • 3. 内存使用率:

线程数过多和队列的大小都会影响此项数据,队列的大小应该通过前期计算线程池任务的条数,来合理的设置队列的大小,不宜过小,让其不会溢出,因为溢出会走拒绝策略,多少会影响性能,也会增加复杂度。

  • 4. 下游系统抗并发能力:

多线程给下游系统造成的并发等于你设置的线程数,例如:如果是多线程访问数据库,你就考虑数据库的连接池大小设置,数据库并发太多影响其 QPS,会把数据库打挂等问题。如果访问的是下游系统的接口,你就得考虑下游系统是否能抗的住这么多并发量,不能把下游系统打挂了。
线程池提供了一种限制和管理资源(包括执行一个任务)的方式。每个线程池还维护一些基本统计信息,例如:已完成任务的数量。

线程池中阻塞队列的作用?为什么是先添加列队而不是先创建最大线程?

1、一般的队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前的任务了,阻塞队列通过阻塞可以保留住当前想要继续入队的任务。

阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入wait状态,释放cpu资源。

阻塞队列自带阻塞和唤醒的功能,不需要额外处理,无任务执行时,线程池利用阻塞队列的take方法挂起,从而维持核心线程的存活、不至于一直占用cpu资源

2、在创建新线程的时候,是要获取全局锁的,这个时候其它的就得阻塞,影响了整体效率。

就好比一个企业里面有10个(core)正式工的名额,最多招10个正式工,要是任务超过正式工人数(task > core)的情况下,工厂领导(线程池)不是首先扩招工人,还是这10人,但是任务可以稍微积压一下,即先放到队列去(代价低)。10个正式工慢慢干,迟早会干完的,要是任务还在继续增加,超过正式工的加班忍耐极限了(队列满了),就的招外包帮忙了(注意是临时工)要是正式工加上外包还是不能完成任务,那新来的任务就会被领导拒绝了(线程池的拒绝策略)。

线程池中线程复用原理

线程池将线程和任务进行解耦,线程是线程,任务是任务,摆脱了之前通过 Thread 创建线程时的一个线程必须对应一个任务的限制。

在线程池中,同一个线程可以从阻塞队列中不断获取新任务来执行,其核心原理在于线程池对 Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新线程,而是让每个线程去执行一个“循环任务”,在这个“循环任务”中不停检查是否有任务需要被执行,如果有则直接执行,也就是调用任务中的 run 方法,将 run 方法当成一个普通的方法执行,通过这种方式只使用固定的线程就将所有任务的 run 方法串联起来。

如何创建线程池?

方式一:通过 ThreadPoolExecutor 的构造方法实现:
方式二:通过 Executor 框架的工具类 Executors 来实现:
我们可以创建三种类型的 ThreadPoolExecutor:
1、FixedThreadPool:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。 底层用LinkedBlockingQueue,表示无界阻塞队列
2、 SingleThreadExecutor:方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先进先出的顺序执行队列中的任务。
3、CachedThreadPool:该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
注意
阿里巴巴Java开发手册中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
Executors 创建线程池对象的弊端如下:
FixedThreadPool 和 SingleThreadExecutor :允许请求的队列长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。
CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

执行 execute() 方法和 submit() 方法的区别是什么呢?

1、 execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
2、 submit() 方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get() 方法来获取返回值,get() 方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit) 方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

说下对 Fork和Join 并行计算框架的理解?

Fork/Join 并行计算框架主要解决的是分治任务。分治的核心思想是“分而治之”:将一个大的任务拆分成小的子任务的结果聚合起来从而得到最终结果。
Fork/Join 并行计算框架的核心组件是 ForkJoinPool。ForkJoinPool 支持任务窃取机制,能够让所有的线程的工作量基本均衡,不会出现有的线程很忙,而有的线程很闲的情况,所以性能很好。
ForkJoinPool 中的任务队列采用的是双端队列,工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费,这样能避免很多不必要的数据竞争。

线程

线程的生命周期?线程有几种状态

线程通常有五种状态,创建,就绪,运行、阻塞和死亡状态:

  1. 新建状态(New):新创建了一个线程对象。
  2. 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。
  3. 运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。
  4. 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。
  5. 死亡状态(Dead):线程执行完了或者因异常退出了run方法,该线程结束生命周期。

阻塞的情况又分为三种:

  1. 等待阻塞:运行的线程执行wait方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待池”中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify或notifyAll方法才能被唤醒,wait是object类的方法
  2. 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入“锁池”中。
  3. 其他阻塞:运行的线程执行sleep或join方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep状态超时、join等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。sleep是Thread类的方法

sleep()、wait()、join()、yield()之间的的区别

锁池:所有需要竞争同步锁的线程都会放在锁池当中,比如当前对象的锁已经被其中一个线程得到,则其他线程需要在这个锁池进行等待,当前面的线程释放同步锁后锁池中的线程去竞争同步锁,当某个线程得到后会进入就绪队列进行等待cpu资源分配。

等待池:当我们调用wait()方法后,线程会放到等待池当中,等待池的线程是不会去竞争同步锁。只有调用了notify()或notifyAll()后等待池的线程才会开始去竞争锁,notify()是随机从等待池选出一个线程放到锁池,而notifyAll()是将等待池的所有线程放到锁池当中

  1. sleep 是 Thread 类的静态本地方法,wait 则是 Object 类的本地方法。
  2. sleep方法不会释放lock,但是wait会释放,而且会加入到等待队列中。

    1. sleep就是把cpu的执行资格和执行权释放出去,不再运行此线程,当定时时间结束再取回cpu资源,参与cpu的调度,获取到cpu资源后就可以继续运行了。而如果sleep时该线程有锁,那么sleep不会释放这个锁,而是把锁带着进入了冻结状态,也就是说其他需要这个锁的线程根本不可能获取到这个锁。也就是说无法执行程序。如果在睡眠期间其他线程调用了这个线程的interrupt方法,那么这个线程也会抛出interruptexception异常返回,这点和wait是一样的。
  3. sleep方法不依赖于同步器synchronized,但是wait需要依赖synchronized关键字。

  4. sleep不需要被唤醒(休眠之后推出阻塞),但是wait需要(不指定时间需要被别人中断)。
  5. sleep 一般用于当前线程休眠,或者轮循暂停操作,wait 则多用于多线程之间的通信。
  6. sleep 会让出 CPU 执行时间且强制上下文切换,而 wait 则不一定,wait 后可能还是有机会重新竞争到锁继续执行的。
  7. yield()执行后线程直接进入就绪状态,马上释放了cpu的执行权,但是依然保留了cpu的执行资格,所以有可能cpu下次进行线程调度还会让这个线程获取到执行权继续执行
  8. join()执行后线程进入阻塞状态,例如在线程B中调用线程A的join(),那线程B会进入到阻塞队列,直到线程A结束或中断线程 ```java public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(new Runnable() {
    1. @Override
    2. public void run() {
    3. try {
    4. Thread.sleep(3000);
    5. } catch (InterruptedException e) {
    6. e.printStackTrace();
    7. }
    8. System.out.println("22222222");
    9. }
    }); t1.start(); t1.join(); // 这行代码必须要等t1全部执行完毕,才会执行 System.out.println(“1111”); }

22222222 1111

  1. <a name="ai6ff"></a>
  2. ## 对线程安全的理解
  3. 不是线程安全、应该是内存安全,堆是共享内存,可以被所有线程访问,当多个线程访问一个对象时,如果不用进行额外的同步控制或其他的协调操作,调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的。
  4. **堆**是进程和线程共有的空间,分全局堆和局部堆。全局堆就是所有没有分配的空间,局部堆就是用户分配的空间。堆在操作系统对进程初始化的时候分配,运行过程中也可以向系统要额外的堆,但是用完了要还给操作系统,要不然就是内存泄漏。在Java中,堆是Java虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建。堆所存在的内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
  5. **栈**是每个线程独有的,保存其运行状态和局部自动变量的。栈在线程开始的时候初始化,每个线程的栈互相独立,因此,栈是线程安全的。操作系统在切换线程的时候会自动切换栈。栈空间不需要在高级语言里面显式的分配和释放。
  6. 目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问别的进程的,这是由操作系统保障的。
  7. 在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成问题的潜在原因。
  8. <a name="MO84D"></a>
  9. ## Thread和Runable的区别
  10. Thread和Runnable的实质是继承关系,没有可比性。无论使用Runnable还是Thread,都会new Thread,然后执行run方法。用法上,如果有复杂的线程操作需求,那就选择继承Thread,如果只是简单的执行一个任务,那就实现runnable。
  11. ```java
  12. //会卖出多一倍的票
  13. public class Test {
  14. public static void main(String[] args) {
  15. // TODO Auto-generated method stub
  16. new MyThread().start();
  17. new MyThread().start();
  18. }
  19. static class MyThread extends Thread{
  20. private int ticket = 5;
  21. public void run(){
  22. while(true){
  23. System.out.println("Thread ticket = " + ticket--);
  24. if(ticket < 0){
  25. break;
  26. }
  27. }
  28. }
  29. }
  30. }
  1. //正常卖出
  2. public class Test2 {
  3. public static void main(String[] args) {
  4. // TODO Auto-generated method stub
  5. MyThread2 mt=new MyThread2();
  6. new Thread(mt).start();
  7. new Thread(mt).start();
  8. }
  9. static class MyThread2 implements Runnable{
  10. private int ticket = 5;
  11. public void run(){
  12. while(true){
  13. System.out.println("Runnable ticket = " + ticket--);
  14. if(ticket < 0){
  15. break;
  16. }
  17. }
  18. }
  19. }
  20. }

原因是:MyThread创建了两个实例,自然会卖出两倍,属于用法错误

对守护线程的理解

守护线程:为所有非守护线程提供服务的线程;任何一个守护线程都是整个JVM中所有非守护线程的保姆;

守护线程类似于整个进程的一个默默无闻的小喽喽;它的生死无关重要,它却依赖整个进程而运行;哪天其他线程结束了,没有要执行的了,程序就结束了,理都没理守护线程,就把它中断了;

注意: 由于守护线程的终止是自身无法控制的,因此千万不要把IO、File等重要操作逻辑分配给它;因为它不靠谱;

守护线程的作用是什么?
举例, GC垃圾回收线程:就是一个经典的守护线程,当我们的程序中不再有任何运行的Thread,程序就不会再产生垃圾,垃圾回收器也就无事可做,所以当垃圾回收线程是JVM上仅剩的线程时,垃圾回收线程会自动离开。它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。
应用场景:(1)来为其它线程提供服务支持的情况;(2) 或者在任何情况下,程序结束时,这个线程必须正常且立刻关闭,就可以作为守护线程来使用;反之,如果一个正在执行某个操作的线程必须要正确地关闭掉否则就会出现不好的后果的话,那么这个线程就不能是守护线程,而是用户线程。通常都是些关键的事务,比方说,数据库录入或者更新,这些操作都是不能中断的。

thread.setDaemon(true)必须在thread.start()之前设置,否则会跑出一个IllegalThreadStateException异常。你不能把正在运行的常规线程设置为守护线程。

在Daemon线程中产生的新线程也是Daemon的。

守护线程不能用于去访问固有资源,比如读写操作或者计算逻辑。因为它会在任何时候甚至在一个操作的中间发生中断。

Java自带的多线程框架,比如ExecutorService,会将守护线程转换为用户线程,所以如果要使用后台线程就不能用Java的线程池。

synchronized和lock区别

  • 来源:lock是一个接口,而synchronized是java的一个关键字,synchronized是内置的 (字节码minorenter minorexit)
  • 异常自动释放锁:synchronized在异常时自动释放锁,lock需手动unlock
  • 响应中断:lock等待锁时可用interrupt中断,synchronized只能等待锁的释放,不响应中断
  • 知道获取锁:lock可以通过trylock来知道有没有获取锁
  • lock可以通过readwritelock实现读写分离,提高多线程读效率
  • 大量线程同时竞争,lock性能高于synchronized。具体使用时根据情况选择
  • synchronized使用object对象的wait、notify、notifyAll调度机制,lock使用Condition进行线程间调度 | 区别类型 | synchronized | lock | | —- | —- | —- | | 存在层次 | jvm层面 | java接口 | | 获取 | A获取到锁 B等待 A阻塞 B一直等待 | 多种获取锁方式,trylock判断是否获取到锁 | | 释放 | 执行完释放 异常释放 | finally中手动释放 | | 类型 | 可重入 非公平 独占锁 不可中断 | 可重入 可公平 可中断 | | 性能 | 少量 | 大量场景 | | 场景 | 独占锁 | 公平与非公平 |

ThreadLocal的底层原理

  1. ThreadLocal是Java中所提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据
  2. ThreadLocal底层是通过ThreadLocalMap来实现的,每个Thread对象(注意不是ThreadLocal对象)中都存在一个ThreadLocalMap,Map的key为ThreadLocal对象,Map的value为需要缓存的值
  3. 如果在线程池中使用ThreadLocal会造成内存泄漏,因为当ThreadLocal对象使用完之后,应该要把设置的key,value,也就是Entry对象进行回收,但线程池中的线程不会回收,而线程对象是通过强引用指向ThreadLocalMap,ThreadLocalMap也是通过强引用指向Entry对象,线程不被回收,Entry对象也就不会被回收,从而出现内存泄漏,解决办法是,在使用了ThreadLocal对象之后,手动调用ThreadLocal的remove方法,手动清楚Entry对象
  4. ThreadLocal经典的应用场景就是连接管理(一个线程持有一个连接,该连接对象可以在不同的方法之间进行传递,线程之间不共享同一个连接)

    ThreadLocal有哪些内存泄漏问题,如何避免

每个thread都有个threadlocal.threadlocalmap的map,该map的key为threadlocal实例,它为一个弱引用。
threadlock的key== null时,gc就会回收这部分空间,但是value却不一定能够被回收,它与currentthread有强引用关系

  • threadlocal不是用于解决共享变量问题,也不是协调线程同步的存在,是为了方便每个线程处理自己的状态而引入的机制
  • 每个thread内部都有一个threadlocal.threadlocalmap类型的成员变量,该变量用来存储实际threadlocal变量副本
  • threadlocal并不是为线程保存对象的副本,仅起到一个索引作用。主要目的为每一个线程隔离一个类的实例,这个实例的作用范围仅限于线程内部

    sleep() 和 wait() 的区别?

  1. sleep() 方法正在执行的线程主动让出 cpu(然后 cpu 就可以去执行其他任务),在 sleep 指定时间后 cpu 再回到该线程继续往下执行(注意:sleep 方法只让出了 cpu,而并不会释放同步资源锁);
    而 wait() 方法则是指当前线程让自己暂时退让出同步资源锁,以便其他正在等待该资源的线程得到该资源进而运行,只有调用了 notify() 方法,之前调用 wait() 的线程才会解除 wait 状态,可以去参与竞争同步资源锁,进而得到执行。
    (注意:notify 的作用相当于叫醒睡着的人,而并不会给他分配任务,就是说 notify 只是让之前调用 wait 的线程有权利重新参与线程的调度);
  2. sleep() 方法可以在任何地方使用,而 wait() 方法则只能在同步方法或同步块中使用;
  3. sleep() 是线程类(Thread)的方法,调用会暂停此线程指定的时间,但监控依然保持,不会释放对象锁,到时间自动恢复;
    wait() 是 Object 的方法,调用会放弃对象锁,进入等待队列,待调用 notify()/notifyAll() 唤醒指定的线程或者所有线程,才会进入锁池,不再次获得对象锁才会进入运行状态。

    线程的 run() 和 start() 有什么区别?

  4. 每个线程都是通过某个特定 Thread 对象所对应的方法 run() 来完成其操作的,方法 run() 称为线程体。通过调用 Thread 类的 start() 方法来启动一个线程;

  5. start() 方法来启动一个线程,真正实现了多线程运行。这时无需等待 run() 方法体代码执行完毕,可以直接继续执行下面的代码;这时此线程是处于就绪状态,并没有运行。然后通过此 Thread 类调用方法 run() 来完成其运行状态,这里方法 run() 称为线程体,它包含了要执行的这个线程的内容,run() 方法运行结束,此线程终止。然后 cpu 再调度其它线程;
  6. start()会执行线程的相应准备工作(启动一个线程并进入就绪状态),然后自动执行run()方法的内容, 直接执行run()方法,会把run()当成一个main线程下的普通方法去执行, run() 方法是在本线程里的,只是线程里的一个函数,而不是多线程的。直接待用 run() 方法必须等待 run() 方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用 start() 方法而不是 run() 方法。

    怎么保证多线程的运行安全?

    线程安全在三个方面体现:
    原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,(atomic,synchronized);
    可见性:一个线程对主内存的修改可以及时地被其他线程看到,(synchronized、volatile);
    有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,(happens-before 原则)。

    线程之间如何进行通讯的

  7. 线程之间可以通过共享内存或基于网络来进行通信

  8. 如果是通过共享内存来进行通信,则需要考虑并发问题,什么时候阻塞,什么时候唤醒
  9. 像Java中的wait()、notify()就是阻塞和唤醒
  10. 通过网络就比较简单了,通过网络连接将通信数据发送给对方,当然也要考虑到并发问题,处理方式就是加锁等方式

    Java 线程同步的几种方法?

  11. 使用 Synchronized 关键字;

  12. wait 和 notify;
  13. 使用特殊域变量 volatile 实现线程同步;
  14. 使用可重入锁实现线程同步;
  15. 使用阻塞队列实现线程同步;
  16. 使用信号量 Semaphore。

    Thread.interrupt() 方法的工作原理是什么?

    在 Java 中,线程的中断 interrupt 只是改变了线程的中断状态,至于这个中断状态改变后带来的结果,那是无法确定的,有时它更是让停止中的线程继续执行的唯一手段。不但不是让线程停止运行,反而是继续执行线程的手段。
    在一个线程对象上调用 interrupt() 方法,真正有影响的是 wait、join、sleep 方法,当然这 3 个方法包括它们的重载方法。请注意:上面这三个方法都会抛出 InterruptedException。
    1、对于 wait 中的等待 notify、notifyAll 唤醒的线程,其实这个线程已经“暂停”执行,因为它正在某一对象的休息室中,这时如果它的中断状态被改变,那么它就会抛出异常。这个 InterruptedException 异常不是线程抛出的,而是 wait 方法,也就是对象的 wait 方法内部会不断检查在此对象上休息的线程的状态,如果发现哪个线程的状态被置为已中断,则会抛出 InterruptedException,意思就是这个线程不能再等待了,其意义就等同于唤醒它了,然后执行 catch 中的代码。
    2、 对于 sleep 中的线程,如果你调用了 Thread.sleep(一年);现在你后悔了,想让它早些醒过来,调用 interrupt() 方法就是唯一手段,只有改变它的中断状态,让它从 sleep 中将控制权转到处理异常的 catch 语句中,然后再由 catch 中的处理转换到正常的逻辑。同样,对于 join 中的线程你也可以这样处理。

    在哪些场景下会使用到 ThreadLocal?

    在调用 API 接口的时候传递了一些公共参数,这些公共参数携带了一些设备信息(是安卓还是 ios),服务端接口根据不同的信息组装不同的格式数据返回给客户端。假定服务器端需要通过设备类型(device)来下发下载地址,当然接口也有同样的其他逻辑,我们只要在返回数据的时候判断好是什么类型的客户端就好了。上面这种场景就可以将传进来的参数 device 设置到 ThreadLocal 中。用的时候取出来就行。避免了参数的层层传递。

    说一说自己对于 synchronized 关键字的了解?

    synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
    另外,在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 JDK6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
    synchronized 关键字底层原理属于 JVM 层面。
    synchronized 同步语句块的情况
    public class SynchronizedDemo { public void method(){ synchronized(this){ System.out.println(“manong qiuzhi xiaozhushou”); } } }
    通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行 javap -c -s -v -l SynchronizedDemo.class

从上面我们可以看出:synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor的持有权。monitor 对象存在于每个 Java 对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因。当计数器为 0 则可以成功获取,获取后将锁计数器设为 1 也就是加 1。相应的在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
synchronized 修饰方法的的情况
public class SynchronizedDemo2 { public synchronized void method() { System.out.println(“manong qiuzhi xiaozhushou”); } }
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用

如何在项目中使用 synchronized 的?

synchronized 关键字最主要的三种使用方式:
1、 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁;
2、修饰静态方法:作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁 。也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员(static 表明这是该类的一个静态资源,不管 new了多少个对象,只有一份,所以对该类的所有对象都加了锁)。所以如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁;
3、 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。和 synchronized 方法一样,synchronized(this) 代码块也是锁定当前对象的。synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。这里再提一下:synchronized 关键字加到非 static 静态方法上是给对象实例上锁。另外需要注意的是:尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓冲功能。
补充:双重校验锁实现单例模式
问到 synchronized 的使用,很有可能让你用 synchronized 实现个单例模式。这里补充下使用 synchronized 双重校验锁的方法实现单例模式:

  1. public class Singleton {
  2. private volatile static Singleton uniqueInstance;
  3. private Singleton() {
  4. }
  5. public static Singleton getUniqueInstance() {
  6. // 先判断对象是否已经实例过,没有实例化过才进入加锁代码
  7. if (uniqueInstance == null) {
  8. // 类对象加锁 synchronized (Singleton.class) {
  9. if (uniqueInstance == null) {
  10. uniqueInstance = new Singleton();
  11. }
  12. }
  13. }
  14. return uniqueInstance;
  15. }

另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1 -> 3 -> 2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

Sychronized和ReentrantLock的区别

  1. sychronized是一个关键字,ReentrantLock是一个类
  2. sychronized会自动的加锁与释放锁,ReentrantLock需要程序员手动加锁与释放锁
  3. sychronized的底层是JVM层面的锁,ReentrantLock是API层面的锁
  4. sychronized是非公平锁,ReentrantLock可以选择公平锁或非公平锁
  5. sychronized锁的是对象,锁信息保存在对象头中,ReentrantLock通过代码中int类型的state标识来标识锁的状态
  6. sychronized底层有一个锁升级的过程

    synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗?

    JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
    锁主要存在四种状态,依次是:
    无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,
    它们会随着竞争的激烈而逐渐升级。
    锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
  • 偏向锁

引入偏向锁的目的和引入轻量级锁的目的很像,它们都是为了没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁在无竞争的情况下使用 CAS 操作去代替使用互斥量。而偏向锁在无竞争的情况下会把整个同步都消除掉。
偏向锁的“偏”就是偏心的偏,它的意思是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步。
但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

  • 轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(JDK1.6 之后加入的)。轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。另外,轻量级锁的加锁和解锁都用到了 CAS 操作。
轻量级锁能够提升程序同步性能的依据是“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还会额外发生 CAS 操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁!

  • 自旋锁和自适应自旋

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态会耗费时间)。
一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的。所以,让后面来的请求获取锁的线程等待一会而不被挂起呢?看看持有锁的线程是否很快就会释放锁。为了让一个线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就叫做自旋

百度百科对自旋锁的解释:何谓自旋锁?它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名 自旋锁在 JDK1.6 之前其实就已经引入了,不过是默认关闭的,需要通过 –XX:+UseSpinning 参数来开启。JDK1.6 及 1.6 之后,就改为默认开启的了。需要注意的是:自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。如果锁被占用的时间短,那么效果当然就很好了。反之,自旋等待的时间必须要有限度。如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是 10 次,用户可以修改 –XX:PreBlockSpin 来更改。

另外,在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不在固定了,而是和前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定,虚拟机变得越来越“聪明”了。

  • 锁消除

锁消除理解起来很简单,它指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。

  • 锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小。只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。
大部分情况下,上面的原则都是没有问题的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能消耗

如何理解volatile关键字

保证被volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
如果线程2改变了stop的值,线程1一定会停止吗?不一定。当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

  1. //线程1
  2. boolean stop = false;
  3. while(!stop){
  4. doSomething();
  5. }
  6. //线程2
  7. stop = true;

禁止指令重排序优化

  1. int a = 0;
  2. bool flag = false;
  3. public void write() {
  4. a = 2; //1
  5. flag = true; //2
  6. }
  7. public void multiply() {
  8. if (flag) { //3
  9. int ret = a * a;//4
  10. }
  11. }

write方法里的1和2做了重排序,线程1先对flag赋值为true,随后执行到线程2,ret直接计算出结果,再到线程1,这时候a才赋值为2,很明显迟了一步。但是用volatile修饰之后就变得不一样了:

  1. 使用volatile关键字会强制将修改的值立即写入主存;
  2. 使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
  3. 由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

inc++; 其实是两个步骤,先加加,然后再赋值。不是原子性操作,所以volatile不能保证线程安全。

synchronized 和 volatile 的区别是什么?

  1. volatile 本质是在告诉 JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  2. volatile 仅能使用在变量级别;synchronized 则可以使用在变量、方法、和类级别的。
  3. volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
  4. volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
  5. volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。

    Java死锁如何避免?

    造成死锁的几个原因:

  6. 一个资源每次只能被一个线程使用

  7. 一个线程在阻塞等待某个资源时,不释放已占有资源
  8. 一个线程已经获得的资源,在未使用完之前,不能被强行剥夺
  9. 若干线程形成头尾相接的循环等待资源关系

这是造成死锁必须要达到的4个条件,如果要避免死锁,只需要不满足其中某一个条件即可。而其中前3个条件是作为锁要符合的条件,所以要避免死锁就需要打破第4个条件,不出现循环等待锁的关系。

在开发过程中:

  1. 要注意加锁顺序,保证每个线程按同样的顺序进行加锁
  2. 要注意加锁时限,可以针对所设置一个超时时间
  3. 要注意死锁检查,这是一种预防机制,确保在第一时间发现死锁并进行解决

    AQS

    对同步器 AQS 的理解?

    AQS 的全称为:AbstractQueuedSynchronizer,这个类在 java.util.concurrent.locks 包下面。
    AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如:我们提到的 ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask 等等皆是基于 AQS 的。当然,我们自己也能利用 AQS 非常轻松容易地构造出符合我们自己需求的同步器
  • 原理

AQS 核心思想是:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

  1. AQS是一个JAVA线程同步的框架。是JDK中很多锁工具的核心实现框架。
  2. 在AQS中,维护了一个信号量state和一个线程组成的双向链表队列。其中,这个线程队列,就是用来给线程排队的,而state就像是一个红绿灯,用来控制线程排队或者放行的。 在不同的场景下,有不用的意义。
  3. 在可重入锁这个场景下,state就用来表示加锁的次数。0标识无锁,每加一次锁,state就加1。释放锁state就减1。

CLH队列:CLH(Craig, Landin, and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。
AQS 使用一个 int 成员变量 (state) 来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。

ReentrantLock中的公平锁和非公平锁的底层实现

首先不管是公平锁和非公平锁,它们的底层实现都会使用AQS来进行排队,它们的区别在于:线程在使用lock()方法加锁时,如果是公平锁,会先检查AQS队列中是否存在线程在排队,如果有线程在排队,则当前线程也进行排队,如果是非公平锁,则不会去检查是否有线程在排队,而是直接竞争锁。

不管是公平锁还是非公平锁,一旦没竞争到锁,都会进行排队,当锁释放时,都是唤醒排在最前面的线程,所以非公平锁只是体现在了线程加锁阶段,而没有体现在线程被唤醒阶段。

另外,ReentrantLock是可重入锁,不管是公平锁还是非公平锁都是可重入的。

ReentrantLock中tryLock()和lock()方法的区别

  1. tryLock()表示尝试加锁,可能加到,也可能加不到,该方法不会阻塞线程,如果加到锁则返回true,没有加到则返回false
  2. lock()表示阻塞加锁,线程会阻塞直到加到锁,方法也没有返回值

    说下对信号量 Semaphore 的理解?

    synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore (信号量)可以指定多个线程同时访问某个资源。
    执行 acquire 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个 release 方法增加一个许可证,这可能会释放一个阻塞的 acquire 方法。然而,其实并没有实际的许可证这个对象,Semaphore 只是维持了一个可获得许可证的数量。Semaphore 经常用于限制获取某种资源的线程数量。当然一次也可以一次拿取和释放多个许可证,不过一般没有必要这样做。除了 acquire方法(阻塞)之外,另一个比较常用的与之对应的方法是 tryAcquire 方法,该方法如果获取不到许可就立即返回 false。

    CountDownLatch和Semaphore的区别和底层原理

    CountDownLatch表示计数器,可以给CountDownLatch设置一个数字,一个线程调用CountDownLatch的await()将会阻塞,其他线程可以调用CountDownLatch的countDown()方法来对CountDownLatch中的数字减一,当数字被减成0后,所有await的线程都将被唤醒。
    对应的底层原理就是,调用await()方法的线程会利用AQS排队,一旦数字被减为0,则会将AQS中排队的线程依次唤醒。
    Semaphore表示信号量,可以设置许可的个数,表示同时允许最多多少个线程使用该信号量,通过acquire()来获取许可,如果没有许可可用则线程阻塞,并通过AQS来排队,可以通过release()方法来释放许可,当某个线程释放了某个许可后,会从AQS中正在排队的第一个线程开始依次唤醒,直到没有空闲许可。

    CountDownLatch 和CyclicBarrier 区别

    CountDownLatch 是计数器,只能使用一次,而 CyclicBarrier 的计数器提供 reset 功能,可以多次使用
    对于 CountDownLatch 来说,重点是“一个线程(多个线程)等待”,而其他的 N 个线程在完成“某件事情”之后,可以终止,也可以等待。而对于 CyclicBarrier,重点是多个线程,在任意一个线程没有完成,所有的线程都必须等待。
    CountDownLatch 是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而 CyclicBarrier 更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。
    CountDownLatch 应用场景:
    1、某一线程在开始运行前等待 n 个线程执行完毕:启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
    2、实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。
    3、死锁检测:一个非常方便的使用场景是,你可以使用 n 个线程访问共享资源,在每次测试阶段的线程数目是不同的,并尝试产生死锁。
    CyclicBarrier 应用场景:
    CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。比如:我们用一个 Excel 保存了用户所有银行流水,每个 Sheet 保存一个帐户近一年的每笔银行流水,现在需要统计用户的日均银行流水,先用多线程处理每个 sheet 里的银行流水,都执行完之后,得到每个 sheet 的日均银行流水,最后,再用 barrierAction 用这些线程的计算结果,计算出整个 Excel 的日均银行流水。

    对ReentrantReadWriteLock的理解?

    ReentrantReadWriteLock 允许多个读线程同时访问,但是不允许写线程和读线程、写线程和写线程同时访问。读写锁内部维护了两个锁:一个是用于读操作的 ReadLock,一个是用于写操作的 WriteLock。读写锁 ReentrantReadWriteLock 可以保证多个线程可以同时读,所以在读操作远大于写操作的时候,读写锁就非常有用了。
    ReentrantReadWriteLock 基于 AQS 实现,它的自定义同步器(继承 AQS)需要在同步状态 state 上维护多个读线程和一个写线程,该状态的设计成为实现读写锁的关键。ReentrantReadWriteLock 很好的利用了高低位。来实现一个整型控制两种状态的功能,读写锁将变量切分成了两个部分,高 16 位表示读,低 16 位表示写。
  • ReentrantReadWriteLock 的特点:

1、写锁可以降级为读锁,但是读锁不能升级为写锁;
2、 不管是 ReadLock 还是 WriteLock 都支持 Interrupt,语义与 ReentrantLock 一致;
3、WriteLock 支持 Condition 并且与 ReentrantLock 语义一致,而 ReadLock 则不能使用 Condition,否则抛出 UnsupportedOperationException 异常;
4、 默认构造方法为非公平模式 ,开发者也可以通过指定 fair 为 true 设置为公平模式 。

  • 升降级
  1. 读锁里面加写锁,会导致死锁;
  2. 写锁里面是可以加读锁的,这就是锁的降级。

    ReentrantLock中tryLock()和lock()方法的区别

  3. tryLock()表示尝试加锁,可能加到,也可能加不到,该方法不会阻塞线程,如果加到锁则返回true,没有加到则返回false

  4. lock()表示阻塞加锁,线程会阻塞直到加到锁,方法也没有返回值

    AQS 对资源的共享模式有哪些?

  5. Exclusive(独占):只有一个线程能执行,如:ReentrantLock,又可分为公平锁和非公平锁:

  6. Share(共享):多个线程可同时执行,如:CountDownLatch、Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock。

    AQS 底层使用了模板方法模式,你能说出几个需要重写的方法吗?

    使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。

  7. isHeldExclusively() :该线程是否正在独占资源。只有用到 condition 才需要去实现它。

  8. tryAcquire(int) :独占方式。尝试获取资源,成功则返回 true,失败则返回 false。
  9. tryRelease(int) :独占方式。尝试释放资源,成功则返回 true,失败则返回 false。
  10. tryAcquireShared(int) :共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  11. tryReleaseShared(int) :共享方式。尝试释放资源,成功则返回 true,失败则返回 false。

    乐观锁悲观锁

    乐观锁常见的两种实现方式是什么?

    乐观锁一般会使用版本号机制或者 CAS 算法实现。
  • 版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加 1。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

  • CAS 算法

即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS 算法涉及到三个操作数:
1、需要读写的内存值 V
2、进行比较的值 A
3、拟写入的新值 B
当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

乐观锁的缺点有哪些?

  • 1. ABA 问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 “ABA” 问题。
JDK 1.5 以后的AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

  • 2. 循环时间长开销大

自旋 CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给 CPU 带来非常大的执行开销。如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用,第一:它可以延迟流水线执行指令(de-pipeline),使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二:它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起 CPU 流水线被清空(CPU pipeline flush),从而提高 CPU 的执行效率。

  • 3. 只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。 但是从 JDK 1.5 开始,提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。所以我们可以使用锁或者利用 AtomicReference 类把多个共享变量合并成一个共享变量来操作。

对悲观锁和乐观锁的理解?

  • 悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如:行锁、表锁、读锁、写锁等,都是在做操作之前先上锁。Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。

  • 乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和 CAS 算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。

  • 两种锁的使用场景

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行 retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

原子类

原子类的理解

Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。所以,所谓原子类说简单点就是具有原子操作特征的类。
并发包 java.util.concurrent 的原子类都存放在 java.util.concurrent.atomic 下。根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类:

  • 1. 基本类型

使用原子的方式更新基本类型:
AtomicInteger:整型原子类
AtomicLong:长整型原子类
AtomicBoolean :布尔型原子类

  • 2. 数组类型

使用原子的方式更新数组里的某个元素:
AtomicIntegerArray:整型数组原子类
AtomicLongArray:长整型数组原子类
AtomicReferenceArray :引用类型数组原子类

  • 3. 引用类型

AtomicReference:引用类型原子类
AtomicStampedReference:原子更新引用类型里的字段原子类
AtomicMarkableReference :原子更新带有标记位的引用类型

  • 4. 对象的属性修改类型

AtomicIntegerFieldUpdater:原子更新整型字段的更新器
AtomicLongFieldUpdater:原子更新长整型字段的更新器
AtomicStampedReference :原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

atomic 的原理是什么?

Atomic 包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。
Atomic 系列的类中的核心方法都会调用 unsafe 类中的几个本地方法。我们需要先知道一个东西就是 Unsafe 类,全名为:sun.misc.Unsafe,这个类包含了大量的对 C 代码的操作,包括很多直接内存分配以及原子操作的调用,而它之所以标记为非安全的,是告诉你这个里面大量的方法调用都会存在安全隐患,需要小心使用,否则会导致严重的后果,例如在通过 unsafe 分配内存的时候,如果自己指定某些区域可能会导致一些类似 C++ 一样的指针越界到其他进程的问题。