目录
三、JUC(java.util.concurrent)的常见类
1)自己使用同步机制(synchronized或者ReentrantLock)
2)Collections.synchronizedList(new ArrayList)
一、synchronized工作原理
基本特点
1、开始是乐观锁,若锁冲突频繁,就转换为悲观锁(自适应)
2、 开始是轻量级锁,若锁被持有的时间较长,就转换为重量级锁(自适应)
3、实现轻量级锁时大概率用到的是自旋锁策略(自适应)
4、不公平锁
5、可重入锁
6、读写锁
1、加锁工作工程
JVM将synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态,会根据情况依次升级
2、其它优化操作
锁消除
编译器+JVM会判断锁是否可消除,若可以就直接消除了
比如我们知道的StringBuffer中的方法都是加了锁的,这里只是在单线程下使用,但append()方法每次调用都要加锁解锁,这没必要,浪费了大量的资源开销,这下就要给锁消除了...
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
锁粗化
一段逻辑中如果出现多次加锁解锁,编译器+JVM会自动进行锁的粗化
锁的粒度:synchronized中代码越多,粒度越粗,反之越细
使用细粒度锁,是期望释放锁时其它线程可以使用,这样能够并发执行的逻辑更多,更有利于充分利用多核cpu资源,但是也有可能实际上并没有线程要来抢占这个锁。这时JVM自动把锁粗化,避免频繁申请释放锁(反复的锁竞争)
打个比方,善良的小明使用公用电话,他有3件事情要给对方交代。但是他怕有别的急着打电话的人,于是他每说一件事就挂掉出去看看有没有人要用电话,没有就再回来说下一件事,有的话就先让给别人...那这还不如你直接3件事赶紧讲完然后走开...
二、Collable接口
Callable接口也是一种创建线程的方式,相当于把线程封装了一个”返回值“
相比Runnable,描述的是一段带有返回值的任务
Callable需要搭配FutureTask使用,用于保存Callable的返回结果,因为往往Callable往往是在另一个线程中执行的,啥时候执行完并不知道
(Callable相当于麻辣烫,FutureTask相当于小票,凭小票取餐)
例子如下,求1~1000之和并返回结果
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
public class demo13 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable=new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum=0;
for (int i = 0; i <= 1000; i++) {
sum+=i;
}
return sum;
}
};
FutureTask<Integer> futureTask=new FutureTask<>(callable);
Thread t1=new Thread(futureTask);
t1.start();
System.out.println(futureTask.get());//t1未执行完,则执行到get()就阻塞等待
}
}
若不使用Callable这样带有返回值的创建方式要从主线程获得执行结果:
static class Result {
public int sum = 0;//保存最终结果
public Object lock = new Object();
}
public static void main(String[] args) throws InterruptedException {
Result result = new Result();
Thread t = new Thread() {
@Override
public void run() {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
synchronized (result.lock) {
result.sum = sum;
result.lock.notify();
}
}
};
t.start();
synchronized (result.lock) {
while (result.sum == 0) {
result.lock.wait();
}
System.out.println(result.sum);
}
}
可以看出,上述代码需要一个辅助类Result,还需要一系列的加锁和wait、notify操作,代码复杂,容易出错;而使用Callable和FutureTask后,代码简化了很多,也不必手写线程同步代码了
小结:线程的创建方式
- 继承Thread,重写run()(创建单独的类/匿名内部类)
- 实现Runnable,重写run()(创建单独的类/匿名内部类)
- 实现Callable,重写call()(创建单独的类/匿名内部类)
- 使用lambda表达式
- 线程工厂 ThreadFactory
- 线程池 ThreadPoolEcecutor
三、JUC(java.util.concurrent)的常见类
ReentrantLock
可重入互斥锁,和synchronized定位相似,都是用来实现互斥效果,保证线程安全
用法
- lock():加锁,若获取不到就死等
- trylock(超时时间):加锁,若获取不到锁,等待一段时间后就放弃加锁
- unlock():解锁
ReentrantLock lock = new ReentrantLock();
-----------------------------------------
lock.lock();
try {
// working
} finally {
lock.unlock()
}
二者 区别:
synchronized是一个关键字,是JVM内部实现的(大概率是基于C++);ReentrantLock是标准库中的一个类,在JVM外实现(基于Java)
- synchronized使用时不需要手动释放锁,ReentrantLock使用时需要手动释放,使用起来更灵活(但是容易遗漏unlock)
- synchronized在申请锁失败时,会死等;ReentrantLock使用时可以通过trylock()的方式等待一段时间就放弃
- synchronized是非公公平锁,ReentrantLock默认是非公平锁,可以通过构造方法传入一个true开启公平锁模式
- 提供了更强大的唤醒机制,synchronized是通过Object的wait/notify实现等待-唤醒机制,每次唤醒的是一个随机等待的线程;ReentrantLock搭配Condition类实现等待-唤醒,可以更精准唤醒某个指定的线程
// ReentrantLock 的构造方法
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
如何选择使用哪个锁?
- 锁竞争不激烈时,使用synchronized效率更高,自动释放更方便
- 锁竞争激烈时,使用ReentrantLock搭配tryLock更灵活控制加锁行为,而不是死等
- 如果需要使用公平锁,使用ReentrantLock
- 还是首选synchronized,ReentrantLock使用更加复杂,尤其是容易忘记加锁;另外synchronized背后还有一些优化手段
原子类
在标准库中提供的 java.util.concurrent.atomic 包里面有原子类,原子类内部用的是CAS,所以性能要比加锁实现i++高很多,原子类有以下几个:
- AtomicBoolean
- AtomicInteger
- AtomicInntegerArry
- AtomicLong
- AtomicReference
- AtomicStampedReference
线程池
ExecutorService 和 Executors
ThreadPoolExecutor
具体详见 多线程基础-案例(三)
信号量 Semaphor
信号量,用来表示”可用资源个数“,本质上是一个计数器
理解信号量
可以把信号量想象成停车场的展示牌:当前有车位100位,表示有100个可用资源
- 当有车开进(申请一个可用资源),可用车位-1(信号量的P操作)
- 当有车开出 (释放一个可用资源),可用车位+1(信号量的V操作)
- 若计数器的值已为0还尝试申请资源,就会阻塞等待,直到有其他线程释放资源
Semaphore的PV操作的加减计数器都是原子的,可以在多线程环境下直接使用
代码示例:
import java.util.concurrent.Semaphore;
public class demo14 {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore=new Semaphore(4);
semaphore.acquire();
System.out.println("P操作");
semaphore.acquire();
System.out.println("P操作");
semaphore.acquire();
System.out.println("P操作");
semaphore.acquire();
System.out.println("P操作");
semaphore.acquire();
System.out.println("P操作");
}
}
使用信号量可以实现”共享锁“:
比如某个资源允许3个线程同时使用,那么就可以使用P操作作为加锁;V操作作为解锁
前三个线程的P操作都能顺利返回,后续线程再进行P操作就会阻塞等待,直到前面的线程执行了V操作
CountDownLatch
同时等待N个任务执行结束
就像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点才能公布成绩
主要适用于多个线程来完成一系列任务 时,用来衡量任务的进度是否完成
比如需要把一个大的任务拆分成多个小的任务并发执行;CountDownLatch就可以判断这些任务是否都完成了
下载文件就可以使用多线程下载(IDM多线程下载成倍的提升下载速度)
代码示例:
- 构造CountDownLatch实例,初始化10个任务需要完成
- 每个任务执行完毕,都调用countDown(),在CountDownLatch内部的计数器同时自减
- 主线程使用await(),阻塞等待所有任务执行完毕(相当于计数器为0了)
import java.util.concurrent.CountDownLatch;
public class demo15 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch=new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int id=i;
Thread t=new Thread(()->{
System.out.println("thread"+id);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//通知说当前任务执行完毕
countDownLatch.countDown();
});
t.start();
}
countDownLatch.await();
System.out.println("所有任务都执行完毕");
}
}
synchronized、ReentrantLock、Semaphore等都可以用于线程同步
四、线程安全的集合类
数据结构中大部分的集合类都是线程不安全的
Vector、Stack、Hashtable线程安全 =>sunchronized
上古时期Java引入的集合类。Stack继承自Vector所以也线程安全了
针对线程不安全的结合类在多线程下就要考虑线程安全问题了
1、多线程环境使用ArrayList
1)自己使用同步机制(synchronized或者ReentrantLock)
2)Collections.synchronizedList(new ArrayList)
synchronizedList是标准库提供的一个基于synchronized进行线程同步的List,关键操作上都带有synchronized
3)使用CopyOnWriteArrayList
CopyOnWrite容器 即 写时拷贝的容器
- 当往容器添加元素,并不是直接往当前容器添加 ,而是先将当前容器进行拷贝,复制出一个新的容器,往这个新容器里添加元素
- 新元素添加完毕,再将原容器的引用指向新的容器
这样就可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素
- 优点:在读多写少的场景下,性能很高,不需要加锁竞争
- 缺点:占用内存较多、新写的数据不能第一时间读取到
2、多线程使用队列
- ArrayBlockingQueue 基于数组实现的阻塞队列
- LinkedBlockingQueue 基于链表实现的 阻塞队列
- PriorityBlockingQueue 基于堆实现的带优先级的阻塞队列
- TransferQueue 最多只包含一个元素的阻塞队列
3、多线程环境使用哈希表
HashMap本身不是线程安全的
在多线程环境下使用哈希表可以使用:Hashtable、ConcurrentHashMap
1)Hashtable
只是简单的把 关键方法加上了synchronized关键字,这相当于直接针对Hashtable对象本身加锁
- 如果多线程访问同一个Hashtable(任意数据)都会直接造成锁冲突
- size属性也是通过synchronized来控制同步,也是比较慢的
- 一旦触发扩容,就由该线程完成整个扩容过程,此过程会涉及到大量的元素拷贝,效率非常低
2)ConcurrentHashMap
相比于HashMap做出了一系列的改进和优化(以Java1.8为例)
- 读操作没有加锁(但是使用了volatile保证从内存读取结果),只对写操作进行加锁
- 加锁的方式仍然是用synchronized,但不是锁整个对象,而是”锁桶“(用每个链表的头结点作为锁对象),大大降低了锁冲突的概率
- 充分利用CAS特性(size属性通过CAS来更新,避免出现重量级锁的情况)
- 优化了扩容方式:化整为零 > 需要扩容的线程,只需要创建一个新的数组,同时搬几个元素过去 > 后续每个来操作map的线程,都会参与搬家过程,每个操作负责搬运一小部分元素 > 搬完最后一个元素再把老数组删掉 > 扩容期间,新老数组同时存在;插入只往新数组插入;查找需要同时查新数组和老数组
读操作不加锁:为了进一步降低锁冲突的概率、保证读到刚修改的数据,搭配了volatile关键字
ConcurrentHashMap“锁桶”技术
Java1.7中采用的是锁分段技术:把若干个哈希桶分成一个”段“(Segment),针对每个段分别加锁
目的是为了降低锁竞争的概率,当2个线程访问的数据恰好在同一个段上时才触发锁竞争;
但其实这也没必要,范围搞大了,修改不是一个段的数据怎么会冲突呢...
Java1.8就取消了分段锁,直接给每个哈希桶(每个链表)分配了一个锁(以每个链表的头节点对象作为锁对象)
Java1.8还在Java1.7上做了别的优化:将原来数组+链表的实现方式改进成了数组+链表/红黑树的方式。当链表较长时(大于等于7个元素)就转换成红黑树
Hashtable和HashMap、ConcurrentHashMap之间的区别:
- HashMap:线程不安全,key允许为null
- Hashtable:线程安全,使用synchronized锁Hashtable对象,效率较低 ,key不允许为null
- ConcurrentHashMap:线程安全,使用synchronized锁每个链表头结点,锁冲突概率低,充 分利用CAS机制,优化了扩容方式,key不允许为null