1.synchronized
1.1.我们现在知道常用的锁策略
- 乐观锁 vs 悲观锁
- 读写锁 vs 普通的互斥锁
- 重量级锁 vs 轻量级锁
- 挂起等待锁 vs 自旋锁
- 公平锁 vs 非公平锁
- 可重入锁 vs 不可重入锁
那synchronized基于这些策略有哪些特性呢?
- synchronized 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁.
- synchronized 不是读写锁,是普通互斥锁
- synchronized 开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁.
- synchronized 实现轻量级锁的时候大概率用到的自旋锁策略,重量级锁的部分基于挂起等待实现
- synchronized 是一种非公平锁
- synchronized 是一种可重入锁
1.2.synchronized 的一些锁优化机制(jdk 1.8)
1.2.1.锁膨胀
JVM 将 synchronized 锁分为四种情况.
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
四种锁状态也会根据实际情况依次进行升级.
- 没有加锁的状态
- 首个线程加锁,就会进入偏向锁状态(只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程,并没有真的加锁)
- 如果偏向锁后续有其他线程来竞争该锁,那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.(自适应的自旋锁)
- 如果竞争进一步激烈,自旋不能快速获取到锁状态,就会膨胀为重量级锁(用到内核提供的mutex.)
1.2.2.锁粗化
锁粗化涉及到锁的粒度
- 锁的粒度: 加锁代码涉及到的范围.(不是整体多个加锁总和的范围,而是单个锁涉及的范围)
- 加锁代码的范围越大,认为锁的粒度越粗.
- 加锁代码的范围越小,则认为粒度越细
- 如果两次加锁之间隔的代码比较多,不会影响整体运行状态的情况下,一般不会优化.
- 如果之间间隔比较小(中间间隔的代码少),间隔过少的时候可能涉及多次释放锁后,又马上申请锁的操作.就很可能触发这个优化(JVM 就会自动把锁粗化)
1.2.3.锁消除
有这么一种情况: 有些代码,明明不用加锁,结果你给加上锁了.编译器就会发现这个加锁好像没啥必要,就直接把锁给去掉了(锁消除).
当然你可能说,我不会乱加锁的.不过,有的时候加锁操作并不是那么明显,稍不留神就会做出了这种错误的决定.
例如: StringBuffer, Vector…这些类,在标准库中进行了加锁操作,而我们在单个线程中用到这些类的时候,就是单线程进行了加锁解锁.而我们并不会发觉,不过我们也不用担心.因为编译器会发现并处理这一类情况,也就是上述的锁消除.
2.Callable接口
2.1.Callable接口的使用
Callable
通常需要搭配
FutureTask
来使用.
FutureTask
用来保存
Callable
的返回结果. 因为
Callable
往往是在另一个线程中执行的, 啥时候执行完并不确定.所以
FutureTask
就可以负责这个
等待结果出来
的工作.
- 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
- 重写 Callable 的 call 方法, 完成一个任务. 直接通过返回值返回计算结果.
- 把 callable 实例使用 FutureTask 包装一下.
- 创建线程, 线程的构造方法传入 FutureTask .此时新线程就会执行 FutureTask 内部的 Callable 的 call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
- 在主线程中调用 task.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果.
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
/**
* Callable 接口
* java.util.concurrent
* <p>
* Callable 解决了 Runnable 不方便返回结果的问题
*/
public class Test {
public static void main(String[] args) {
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;
}
};
//创建线程,完成任务
//为了让线程执行 callable 中的任务,光使用构造方法还不够,还需要一个辅助的类
//FutureTask 运行结果会保存在这个类中
FutureTask<Integer> task = new FutureTask<>(callable);//任务
Thread t = new Thread(task);
t.start();
try {
//如果线程的任务没有执行完,get就会阻塞,一直阻塞到,有return 的结果
System.out.println(task.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
2.2.Callable 和 Runnable 比较
- 都是描述一个任务
- Callable 描述的是带有返回值的任务.
- Runnable 描述的是不带返回值的任务.
3.JUC(java.util.concurrent) 中的常见类
3.1.ReentrantLock:可重入锁
有三个主要的方法
- lock()方法 : 加锁,如果获取不到锁就死等.
- unlock()方法 : 解锁
- trylock(超时时间)方法 : 也是加锁,不同于lock()方法的是这个方法懂得放弃加锁失败,不会死等,而是等待一定的时间之后就放弃加锁
相对于习惯使用 synchronized 的我来说,这里加锁和解锁 分开的做法,是不太友好的,因为很容易遗漏 unlock(),出现锁死.
3.1.1.ReentrantLock与synchronized的区别
-
synchronized是一个关键字(背后的逻辑是JVM内部实现的,C++),ReentrantLock是一个标准库中的类(背后的逻辑是Java代码写的)
-
synchronized 不需要手动释放锁,出了代码块,锁自然释放.ReentrantLock必须要手动释放锁,要谨防忘记释放.
-
synchronized 如果竞争锁的时候失败,就会阻塞等待.但是ReentrantLock除了阻塞等待这一手之外,还有一手, trylock()超时失败了会返回.
-
synchronized是一个非公平锁.ReentrantLock 提供了非公平和公平锁两个版本!在构造方法中,通过参数就可以来指定当前是公平锁还是非公平锁.
- ReentrantLock locker = new ReentrantLock(true);
- 参数为true 时该类为公平锁
- 参数默认是false 默认该类为非公平锁
3.1.1.ReentrantLock类使用
import java.util.concurrent.locks.ReentrantLock;
/**
* ReentrantLock : 可重入锁
*/
public class Test2 {
public static void main(String[] args) {
ReentrantLock locker = new ReentrantLock(true);
//参数为true 为公平锁 默认为false 非公平锁
locker.lock();//加锁
try {
} finally {
locker.unlock();//解锁
}
}
}
3.2.Semaphore:信号量
锁就相当于一个二元信号量,可用资源只有一个.计数器非0,即1
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.
类的参数为信号量个数
3.2.1.PV操作
- acquire()方法 P操作(申请资源):无资源的时候获取会阻塞等待资源释放
- release()方法 V操作(释放资源)
3.2.2.Semaphore类使用
import java.util.concurrent.Semaphore;
/**
* Semaphore 信号量
* 锁:二元信号量, 可用资源就一个,计数器 非0,即1
*/
public class Test {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(4);
//参数为4 代表可用资源有 4 个
//申请资源 P操作
semaphore.acquire();//1
System.out.println("申请成功");
semaphore.acquire();//2
System.out.println("申请成功");
semaphore.acquire();//3
System.out.println("申请成功");
semaphore.acquire();//4
System.out.println("申请成功");
// semaphore.acquire();//5 阻塞等待
// System.out.println("申请成功");
semaphore.release();//释放资源 V操作
semaphore.acquire();
System.out.println("申请成功");
}
}
3.3.CountDownLatch 同时等待N个任务执行结束.
类的参数.表示需要等待任务的个数.
3.3.1.方法使用
- countDown()方法.任务调用此方法表示任务结束.此时CountDownLatch内部的计数器减1.
- await()方法.调用此方法的线程阻塞等待所有任务执行完毕. 就是计数器减为0的时候.
3.3.2.CountDownLatch类使用
import java.util.concurrent.CountDownLatch;
/**
* CountDownLatch 同时等待 N 个任务执行结束
*/
public class Test {
public static void main(String[] args) throws InterruptedException {
//构造方法的参数表示 有多少个选手参赛
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
Thread t = new Thread(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "到达终点");
countDownLatch.countDown();
});
t.start();
}
countDownLatch.await();//等待所有线程到达
System.out.println("比赛结束");
}
}
3.4.CopyOnWriteArrayList 写时拷贝
当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy.复制出一个新的容器后往新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器.
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为只读的时候不会有线程安全问题.
3.5.ConcurrentHashMap 多线程环境下可以使用的哈希表
ConcurrentHashMap 相比于 Hashtable 又做出了一系列的改进和优化
-
读操作没有加锁(但是使用了 volatile 保证从内存读取结果),只对写操作进行加锁.加锁的方式仍然是用 synchronized,但不是对整个对象加锁, 而是
“锁桶”
(用每个链表的头结点作为锁对象,让锁加到每个链表的头结点上), 大大降低了锁冲突的概率. - 充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况.
-
优化了扩容方式: 化整为零
- 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
- 扩容期间, 新老数组同时存在.
- 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小 部分元素.
- 搬完最后一个元素再把老数组删掉.
- 这个期间, 插入只往新数组加.
- 这个期间, 查找需要同时查新数组和老数组.