目录
- 1、
- 1、说说List, Set, Queue, Map 四者的区别
- 1.1、List
- 1.2、Set
- 1.3、Map
- 2、如何选用集合
- 4、线程安全的集合有哪些?线程不安全的呢?
- 3、为什么需要使用集合
- 4、comparable和Comparator的区别
- 5、无序性和不可重复性的含义
- 6、比较HashSet、LinkedHashSet和TreeSet的异同
- 7、HashMap和Hashtable的区别
- 8、HashSet和HashMap的区别
- 9、HashMap和TreeMap的区别
- 10、HashSet如何检查重复
- 11、线程和进程有什么区别?
- 12、有几种方式创建多线程
- 13、线程的生命周期/线程有哪些状态
- 14、为什么要使用多线程呢
- 15、使用多线程可能带来什么问题
- 16、说说线程的生命周期和状态
- 17、什么是线程死锁,如何避免线程死锁
- 18、为什么我们调用start()方法时会执行run()方法,为什么我们不能直接调用Thread类的run方法
- 19、谈一下你对 volatile 关键字的理解
- 20、sleep()方法和 wait()方法
- 21、yield() 和 join() 方法的区别
- 22、线程阻塞的三种情况
- 23、线程死亡的三种方式
- 24、如何使用synchronized
- 25、Synchronized的作用有哪些
- 26、synchronized和volatile的区别是什么
- 27、synchronized和Lock有什么区别
- 28、构造方法可以用 synchronized 修饰吗
- 29、ThreadLocal有什么用
- 30、知道ThreadLocal内存泄漏问题吗
- 31、为什么要用线程池
- 32、如何创建线程池
- 33、线程池执行任务的流程
- 34、核心线程数和最大线程数有什么区别
- 35、线程池的饱和策略有哪些
- 36、如何设置线程池的大小
- 37、Executor和Executors的区别
- 38、谈谈乐观锁和悲观锁
- 39、AQS是什么
- 40、AQS的原理是什么
- 41、AQS组件了解吗
- 42、Semaphore有什么用
- 43、CountDownLatch有什么用
- 44、CountDownLatch的原理是什么
- 45、用过CountDownLatch么?什么场景下用的?
- 46、CyclicBarrier有什么用
- 47、CyclicBarrier的原理是什么
- 48、Iterator怎么使用,有什么特点?
- 49、Collection和Collections有什么区别?
- 50、在Java程序中怎么保证多线程的运行安全
- 51、Java线程同步的几种方式
- 52、简单说下对 Java 中的原子类的理解
- 53、谈谈对CopyOnWriteArrayList的理解
- 54、说下你对 Java 内存模型的理解
Collection是一个接口,Collections是一个工具类,Map不是Collection的子接口。
List
(对付顺序的好帮手): 存储的元素是有序的、可重复的。可直接根据元素的索引来访问Set
(注重独一无二的性质): 存储的元素是无序的、不可重复的,只能根据元素本身来访问Queue
(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。Map
(用 key 来搜索的专家): 使用键值对(key-value)存储,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。Java 集合, 也叫作容器,主要是由两大接口派生而来:一个是 Collection
接口,主要用于存放单一元素。另一个是 Map
接口,主要用于存放键值对。对于Collection
接口,下面又有三个主要的子接口:List
、Set
和 Queue
ArrayList
: 基于动态数组实现,支持随机访问。Vector
:和 ArrayList 类似,但它是线程安全的LinkedList
: 基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。HashSet
(无序,唯一): 基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的
LinkedHashSet
: LinkedHashSet
是 HashSet
的子类,并且其内部是通过 LinkedHashMap
来实现的,使用双向链表维护元素的插入顺序
TreeSet
(有序,唯一): 基于红黑树实现,支持有序性操作,例如:根据一个范围查找元素的操作。但是查找效率不如 HashSet
HashMap
: JDK1.8 之前 HashMap
由数组+链表组成的,数组是 HashMap
的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间LinkedHashMap
: LinkedHashMap
继承自 HashMap
,所以它的底层仍然是基于拉链式散列结构。即由数组和链表或红黑树组成Hashtable
:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用 ConcurrentHashMap 来支持线程安全,并且 ConcurrentHashMap 的效率会更高,因为 ConcurrentHashMap 引入了分段锁。TreeMap
: 红黑树需要根据键值获取到元素值时就选用 Map
接口下的集合
TreeMap
HashMap
ConcurrentHashMap
当我们只需要存放元素值时,就选择实现Collection
接口的集合:
Set
接口的集合比如 TreeSet
或 HashSet
List
接口的比如 ArrayList
或 LinkedList
线程安全的:
线性不安全的:
当我们需要保存一组类型相同的数据的时候,我们应该是用一个容器来保存,这个容器就是数组,但是,使用数组存储对象具有一定的弊端, 因为我们在实际开发中,存储的数据的类型是多种多样的,于是,就出现了“集合”,集合同样也是用来存储多个数据的。
数组的缺点是一旦声明之后,长度就不可变了;同时,声明数组时的数据类型也决定了该数组存储的数据的类型;而且,数组存储的数据是有序的、可重复的,特点单一。 但是集合提高了数据存储的灵活性,Java 集合不仅可以用来存储不同类型不同数量的对象,还可以保存具有映射关系的数据
comparable
接口实际上是出自java.lang
包 它有一个 compareTo(Object obj)
方法用来排序comparator
接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)
方法用来排序一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo()
方法或compare()
方法,当我们需要对某一个集合实现两种排序方式,比如一个 song 对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写compareTo()
方法和使用自制的Comparator
方法或者以两个 Comparator 来实现歌名排序和歌星名排序。
Comparable
,如果需要按照对象的不同属性进行排序,请选择 Comparator
。equals()
判断时 ,返回 false,需要同时重写 equals()
方法和 hashCode()
方法。HashSet
、LinkedHashSet
和 TreeSet
都是 Set
接口的实现类,都能保证元素唯一,并且都不是线程安全的。HashSet
、LinkedHashSet
和 TreeSet
的主要区别在于底层数据结构不同。
HashSet
的底层数据结构是哈希表(基于 HashMap
实现)LinkedHashSet
的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet
底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序HashSet
用于不需要保证元素插入和取出顺序的场景LinkedHashSet
用于保证元素的插入和取出顺序满足 FIFO 的场景TreeSet
用于支持对元素自定义排序规则的场景HashMap
是非线程安全的,Hashtable
是线程安全的,因为 Hashtable
内部的方法基本都经过synchronized
修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap
吧!HashMap
要比 Hashtable
效率高一点。另外,Hashtable
基本被淘汰,不要在代码中使用它HashMap
可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值HashMap的 size 为什么必须是 2 的整数次方?
这样做总是能够保证 HashMap 的底层数组长度为 2 的 n 次方,这样在计算机中转化二进制和进行与操作可以增加速度,这是 HashMap 在速度上的一个优化
HashMap 的 get 方法能否判断某个元素是否在 map 中?
HashMap 的 get 函数的返回值不能判断一个 key 是否包含在 map 中,因为 get 返回 null 有可能是不包含该 key,也有可能该 key 对应的 value 为 null。因为 HashMap 中允许 key 为 null,也允许 value 为 null。
LinkedHashMap的实现原理
LinkedHashMap 是基于 HashMap 实现的,但是多了header、before、after三个属性,有了这三个属性就能组成一个双向链表,来实现按插入顺序或访问顺序排序,其迭代顺序默认为插入顺序。
HashMap | HashSet |
---|---|
实现了Map接口 | 实现Set接口 |
存储键值对 | 仅存储对象 |
调用 put() 向 map 中添加元素 | 调用 add() 方法向 set 中添加元素 |
HashMap使用键key来计算 Hashcode | HashSet使用成员对象来计算 hashcode,再用 equals() 方法来判断对象的相等性 |
HashMap相对于HashSet较快,因为它是使用唯一的键获取对象 |
HashSet的底层其实就是HashMap,只不过我们HashSet是实现了Set接口并且把数据作为K值,而V值一直使用一个相同的虚值来保存。
TreeMap
和HashMap
都继承自AbstractMap
,但是需要注意的是TreeMap
它还实现了NavigableMap
接口和SortedMap
接口。
TreeMap
主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力
为什么HashMap中String、Integer这样的包装类适合作为key?
String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少Hash碰撞的几率
equals()
、hashCode()
等方法,遵守了HashMap内部的规范,不容易出现Hash值计算错误的情况当你把对象加入HashSet
时,HashSet
会先计算对象的hashcode
值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode
值作比较,如果没有相符的 hashcode
,HashSet
会假设对象没有重复出现。但是如果发现有相同 hashcode
值的对象,这时会调用equals()
方法来检查 hashcode
相等的对象是否真的相同。如果两者相同,HashSet
就不会让加入操作成功。
HashSet的实现原理?
HashSet 的实现是依赖于 HashMap 的,HashSet 的值都是存储在 HashMap 中的。在 HashSet 的构造方法中会初始化一个 HashMap 对象,HashSet 不允许值重复。因此,HashSet 的值是作为 HashMap 的 key 存储在 HashMap 中的,当存储的值已经存在时返回 false。
通常一个进程都有若干个线程,至少包含一个线程。
第一种方式:继承 Thread 类,并重写该类的 run() 方法,然后创建线程对象,调用线程对象的 start() 方法启动线程。
第二种方式:实现 Runnable 接口,重写该接口的 run() 方法,然后创建实现类的实例,并将此实例作为 Thread 的 target 来创建线程对象,调用线程对象的 start() 方法启动线程。
第三种方式:通过 Callable 和 Future 创建线程。实现Callable
接口,重写 call() 方法,创建一个Callable
的线程任务对象,把Callable
的线程任务对象包装成一个未来任务对象,把未来任务对象包装成线程对象,调用线程的start()
方法启动线程
第四种方式:通过线程池创建线程
Thread和Runnable的区别
总结:
实现Runnable接口比继承Thread类所具有的优势:
Runnable 和 Callable 有什么区别?
线程通常有五种状态:创建、就绪、运行、阻塞和死亡状态
阻塞情况又分为三种:
先从总体上来说:
并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
线程有6种状态:
start()
start()
等待运行的状态线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。线程创建之后它将处于 NEW(新建) 状态,调用 start()
方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。当线程执行 wait()
方法之后,线程进入 WAITING(等待) 状态。通过 sleep(long millis)
方法或 wait(long millis)
方法可以将线程置于 TIMED_WAITING 状态。当线程进入 synchronized
方法/块或者调用 wait
后(被 notify
)重新进入 synchronized
方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。线程在执行完了 run()
方法之后将会进入到 TERMINATED(终止) 状态
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
死锁产生的四个必要条件:
如何预防死锁? 破坏死锁的产生的必要条件即可:
**如何避免死锁?**避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
new 一个 Thread
,线程进入了新建状态。调用 start()
方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start()
会执行线程的相应准备工作,然后自动执行 run()
方法的内容,这是真正的多线程工作。 但是,直接执行 run()
方法,会把 run()
方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结: 调用 start()
方法方可启动线程并使线程进入就绪状态,直接执行 run()
方法的话不会以多线程的方式执行(run 方法只是thread 的一个普通方法调用,还是在主线程里执行。)
线程的 run() 和 start() 有什么区别?
volatile 关键字是用来保证有序性和可见性的。这跟 Java 内存模型有关。我们所写的代码,不一定是按照我们自己书写的顺序来执行的,编译器会做重排序,CPU 也会做重排序的,这样做是为了减少流水线阻塞,提高 CPU 的执行效率。这就需要有一定的顺序和规则来保证,不然程序员自己写的代码都不知道对不对了
被 volatile 修饰的共享变量,具有以下两点特性:
在 Java 中,volatile
关键字可以保证变量的可见性,如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取
volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证
首先明白两个概念:锁池和等待池
wait()
方法后,线程会放到等待池中,等待池的线程是不会去竞争同步锁。只有调用了 notify()
或 notifyAll()
方法后等待池的线程才会开始去竞争锁, notify()
是随机从等待池选出一个线程放到锁池,而 notifyAll()
方法是将等待池的所有线程放到锁池当中。区别:
相同:
yield()
执行后线程直接进入就绪状态,马上释放 cpu,但是依然保留了cpu的执行资格,所以有可能cpu下次进行线程调入还会让这个线程获取到执行权继续执行。就是让别的线程去抢cpu,如果其他线程抢不到cpu,那么仍然是这个线程执行join()
执行后线程进入阻塞状态,例如在线程B调用线程A的 join() ,那么线程 B会进入到阻塞队列,直到线程A结束或中断线程public class Hello {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("22222");
}
});
t1.start();
// 调用 t1的join()方法,会将 main 线程进行阻塞
t1.join();
//所以会先打印t1的内容 22222,再打印main的内容 1111
System.out.println("1111");
}
}
当线程因为某种原因放弃 CPU 使用权后,即让出了 CPU 时间片,暂时就会停止运行,直到线程进入可运行状态(Runnable
),才有机会再次获得 CPU 时间片转入 RUNNING
状态。一般来讲,阻塞的情况可以分为如下三种:
RUNNING
状态的线程执行 Object.wait()
方法后,JVM 会将线程放入等待序列RUNNING
状态的线程在获取对象的同步锁时,若该 同步锁被其他线程占用,则 JVM 将该线程放入锁池RUNNING
状态的线程执行 Thread.sleep(long ms)
或 Thread.join()
方法,或发出 I/O 请求时,JVM 会将该线程置为阻塞状态。当 sleep()
状态超时,join()
等待线程终止或超时. 或者 I/O 处理完毕时,线程重新转入可运行状态(RUNNABLE
)run()
或者 call()
方法执行完成后,线程正常结束Exception
或 Error
,导致线程异常结束stop()
方法来结束该线程,但是一般不推荐使用该种方式,因为该方法通常容易导致死锁synchronized
关键字的使用方式主要有下面 3 种:
给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
synchronized void method() {
//业务代码
}
给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。
synchronized static void method() {
//业务代码
}
静态 synchronized
方法和非静态 synchronized
方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized
方法,是允许的,不会发生互斥现象,因为访问静态 synchronized
方法占用的锁是当前类的锁,而访问非静态 synchronized
方法占用的锁是当前实例对象锁。
synchronized(object)
表示进入同步代码库前要获得 给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得 给定 Class 的锁synchronized(this) {
//业务代码
}
总结:
synchronized
关键字加到 static
静态方法和 synchronized(class)
代码块上都是是给 Class 类上锁synchronized
关键字加到实例方法上是给对象实例上锁synchronized(String a)
因为 JVM 中,字符串常量池具有缓存功能volatile
解决的是内存可见性问题,会使得所有对 volatile
变量的读写都直接写入主存,即 保证了变量的可见性。``synchronized` 既保证了原子性,也保证了可见性。
区别:
**构造方法不能使用 synchronized 关键字修饰。**构造方法本身就属于线程安全的,不存在同步的构造方法一说
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?
ThreadLocal,即线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题
在那些场景下会使用 ThreadLocal?
数据库连接、处理数据库事务
数据跨层传递
ThreadLocalMap中使用的 key 为 ThreadLocal 的弱引用(弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存)。弱引用比较容易被回收。因此,如果ThreadLocal(ThreadLocalMap的Key)被垃圾回收器回收了,但是因为ThreadLocalMap生命周期和Thread是一样的,它这时候如果不被回收,就会出现这种情况:ThreadLocalMap的key没了,value还在,这就会**「造成了内存泄漏问题」**。
如何**「解决内存泄漏问题」**?使用完ThreadLocal后,及时调用remove()方法释放内存空间。
降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控
JDK中提供了哪些并发容器?
JDK 提供的这些容器大部分在 java.util.concurrent 包中:
《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
Executors创建线程池对象的弊端如下:FixedThreadPool 和 SingleThreadExecutor :允许请求的队列长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM(内存用完)。CachedThreadPool: 允许创建的线程数量为Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
方式一:通过 Executor 框架的工具类 Executors 来实现,我们可以创建三种类型的 ThreadPoolExecutor:
FixedThreadPool :
SingleThreadExecutor: 方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
CachedThreadPool:
方式二:通过 ThreadPoolExecutor 的构造方法实现
创建线程池的参数有哪些?
线程池中的线程数一般怎么设置?需要考虑哪些问题?
主要考虑下面几个方面:
线程池中线程执行任务的性质
计算密集型的任务比较占 cpu,所以一般线程数设置的大小 等于或者略微大于 cpu 的核数;但 IO 型任务主要时间消耗在 IO 等待上,cpu 压力并不大,所以线程数一般设置较大。
cpu 使用率
当线程数设置较大时,会有如下几个问题:第一,线程的初始化,切换,销毁等操作会消耗不小的 cpu 资源,使得 cpu 利用率一直维持在较高水平。第二,线程数较大时,任务会短时间迅速执行,任务的集中执行也会给 cpu 造成较大的压力。第三, 任务的集中支持,会让 cpu 的使用率呈现锯齿状,即短时间内 cpu 飙高,然后迅速下降至闲置状态,cpu 使用的不合理,应该减小线程数,让任务在队列等待,使得 cpu 的使用率应该持续稳定在一个合理,平均的数值范围。
内存使用率
线程数过多和队列的大小都会影响此项数据,队列的大小应该通过前期计算线程池任务的条数,来合理的设置队列的大小,不宜过小,让其不会溢出,因为溢出会走拒绝策略,多少会影响性能,也会增加复杂度。
下游系统抗并发能力
多线程给下游系统造成的并发等于你设置的线程数,如果访问的是下游系统的接口,你就得考虑下游系统是否能抗的住这么多并发量,不能把下游系统打挂了。
执行 execute() 方法和 submit() 方法的区别是什么呢?
ThreadPoolExecutor
3 个最重要的参数:
corePoolSize
: 核心线程数定义了最小可以同时运行的线程数量。maximumPoolSize
: 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。ThreadPoolExecutor
其他常见参数:
keepAliveTime
:当线程池中的线程数量大于 corePoolSize
的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime
才会被回收销毁;
unit
: keepAliveTime
参数的时间单位。
threadFactory
:executor 创建新线程的时候会用到。
handler
:饱和策略
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor
定义一些策略:
ThreadPoolExecutor.AbortPolicy
: 抛出 RejectedExecutionException
来拒绝新任务的处理。
ThreadPoolExecutor.CallerRunsPolicy
: 调用执行自己的线程运行任务,也就是直接在调用execute
方法的线程中运行(run
)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
ThreadPoolExecutor.DiscardPolicy
: 不处理新任务,直接丢弃掉。
ThreadPoolExecutor.DiscardOldestPolicy
: 此策略将丢弃最早的未处理的任务请求
举个例子:Spring 通过 ThreadPoolTaskExecutor
创建线程池的时候,当我们不指定 RejectedExecutionHandler
饱和策略的话来配置线程池的时候默认使用的是 ThreadPoolExecutor.AbortPolicy
。在默认情况下,ThreadPoolExecutor
将抛出 RejectedExecutionException
来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。对于可伸缩的应用程序,建议使用 ThreadPoolExecutor.CallerRunsPolicy
。当最大池被填满时,此策略为我们提供可伸缩队列。
如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
有一个简单并且适用面比较广的公式:
CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
Executors 工具类的不同方法按照我们的需求创建了不同的线程池,来满足业务的需求
Executor 接口对象能执行我们的线程任务。ExecutorService接口继承了Executor接口并进行了扩展,提供了更多的方法我们能获得任务执行的状态并且可以获取任务的返回值。
使用ThreadPoolExecutor 可以创建自定义线程池。
乐观锁是CAS [Compare And Swap(比较再交换)],悲观锁是 synchronized
两种锁的使用场景:
乐观锁的常见的两种实现方式是什么?
乐观锁一般会使用版本号机制或者 CAS 算法实现。
版本号机制
一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加 1。
CAS 算法
即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步
乐观锁的缺点有哪些?
ABA 问题
如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 “ABA” 问题。
循环时间长开销大
自旋 CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给 CPU 带来非常大的执行开销。
只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。 但是从 JDK 1.5 开始,提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。所以我们可以使用锁或者利用 AtomicReference 类把多个共享变量合并成一个共享变量来操作
CAS和 synchronized 的使用场景?
简单的来说 CAS 适用于写比较少的情况下(多读场景,冲突一般较少),synchronized 适用于写比较多的情况下(多写场景,冲突一般较多)。
AQS 的全称为 AbstractQueuedSynchronizer
,翻译过来的意思就是抽象队列同步器,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock
,Semaphore
,是基于 AQS 的。
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中。
AQS 使用 int 成员变量 state
表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。
以 ReentrantLock
为例,state
初始值为 0,表示未锁定状态。A 线程 lock()
时,会调用 tryAcquire()
独占该锁并将 state+1
。此后,其他线程再 tryAcquire()
时就会失败,直到 A 线程 unlock()
到 state=
0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state
会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。
再以 CountDownLatch
以例,任务分为 N 个子线程去执行,state
也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后countDown()
一次,state 会 CAS(Compare and Swap) 减 1。等到所有子线程都执行完后(即 state=0
),会 unpark()
主调用线程,然后主调用线程就会从 await()
函数返回,继续后余动作。
AQS对资源的共享模式有哪些?
AQS 底层使用了模板方式模式,你能说出几个需要重写的方法吗?
使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源
CountDownLatch (倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行
CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。
synchronized
和 ReentrantLock
都是一次只允许一个线程访问某个资源,而Semaphore
(信号量)可以用来控制同时访问特定资源的线程数量。
Semaphore 的使用简单,我们这里假设有 N(N>5) 个线程来获取 Semaphore
中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。
// 初始共享资源数量
final Semaphore semaphore = new Semaphore(5);
// 获取1个许可
semaphore.acquire();
// 释放1个许可
semaphore.release();
Semaphore
有两种模式:
acquire()
方法的顺序就是获取许可证的顺序,遵循 FIFO;Semaphore
通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)
CountDownLatch
允许 count
个线程阻塞在一个地方,直至所有线程的任务都执行完毕。CountDownLatch允许一个或多个线程等待其他线程完成操作,再执行自己。CountDownLatch是通过一个计数器来实现的,每当一个线程完成了自己的任务后,可以调用countDown()方法让计数器-1,当计数器到达0时,调用CountDownLatch。
CountDownLatch
是共享锁的一种实现,它默认构造 AQS 的 state
值为 count
。当线程使用 countDown()
方法时,其实使用了tryReleaseShared
方法以 CAS 的操作来减少 state
,直至 state
为 0 。当调用 await()
方法的时候,如果 state
不为 0,那就证明任务还没有执行完毕,await()
方法就会一直阻塞,也就是说 await()
方法之后的语句不会被执行。然后,CountDownLatch
会自旋 CAS 判断 state == 0
,如果 state == 0
的话,就会释放所有等待的线程,await()
方法之后的语句得到执行。
使用多线程读取多个文件处理的场景,我用到了 CountDownLatch
。要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。为此我们定义了一个线程池和 count 为 6 的CountDownLatch
对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch
对象的 await()
方法,直到所有文件读取完之后,才会接着执行后面的逻辑。
CyclicBarrier
和 CountDownLatch
非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch
更加复杂和强大。
CyclicBarrier
的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活CyclicBarrier
内部通过一个 count
变量作为计数器,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
CountDownLatch 和 CyclicBarrier 有什么区别?
CountDownLatch 是计数器,只能使用一次,而 CyclicBarrier 的计数器提供 reset 功能,可以多次使用。
对于 CountDownLatch 来说,重点是“一个线程(多个线程)等待”,而其他的 N 个线程在完成“某件事情”之后,可以终止,也可以等待。而对于 CyclicBarrier,重点是多个线程,在任意一个线程没有完成,所有的线程都必须等待。
CountDownLatch 是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而 CyclicBarrier 更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。
CountDownLatch 应用场景:
CyclicBarrier 应用场景:
CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。我们用一个 Excel 保存了用户所有银行流水,每个 Sheet 保存一个帐户近一年的每笔银行流水,现在需要统计用户的日均银行流水,先用多线程处理每个 sheet 里的银行流水,都执行完之后,得到每个 sheet 的日均银行流水,最后,再用 barrierAction 用这些线程的计算结果,计算出整个 Excel 的日均银行流水。
迭代器是一种设计模式,它是一个对象,它可以遍历并选择序列中的对象,而开发人员不需要了解该序列的底层结构。
Iterator 和 ListIterator 有什么区别?
线程安全在三个方面体现:
这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。所以,所谓原子类说简单点就是具有原子操作特征的类。
在很多应用场景中,读操作可能会远远大于写操作。由于读操作根本不会修改原有的数据,因此对于每次读取都进行加锁其实是一种资源浪费。我们应该允许多个线程同时访问 List 的内部数据,毕竟读取操作是安全的。
CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,我们并不需要修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。
先copy再write,读的时候无需控制无需加锁,写的时候要加锁,不是为了不让读,而是避免了多线程写的时候会 copy 出多个副本出来。
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中,每个线程有自己的工作线程(Working Memory),保存主内存副本拷贝和自己私有变量,不同线程不能访问工作内存中的变量。线程间变量值的传递需要通过主内存来完成。
个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。所以,所谓原子类说简单点就是具有原子操作特征的类。