以JDK1.8为例:
这些典型的优化手段都是编译器、操作系统、jvm、CPU相互配合完成的。
体现了synchronized能够“自适应”的能力。
加锁的工作过程如下:
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
(1) 偏向锁
第一个尝试加锁的线程, 优先进入偏向锁状态。
偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程。
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)。
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态。
偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销。
但是该做的标记还是得做的, 否则无法区分何时需要真正加锁。
举个不恰当的例子:现在很多年轻人同居但不结婚,这个同居就如同我们之间做了一个标记一样,它的好处就是我们不用像结婚之后承担那么多东西,也可以很甜蜜的生活在一起。但是有一天,男方或者女方有其他的人在追,于是另外一方就希望赶紧结婚,将这段关系进行法律上的认证,让其他追求者死心。那么这个结婚过程就是偏向锁升级到轻量级锁的过程。
(2)轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁)。
此处的轻量级锁就是通过 CAS 来实现:
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.
也就是所谓的 “自适应”。
(3)重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁。
此处的重量级锁就是指用到内核提供的 mutex :
锁粗化对应的就是锁细化,这里的粗细指的是锁的粒度。
锁的粒度:加锁代码涉及到的范围,加锁代码的范围越大,认为锁的粒度越粗;范围越小,则认为粒度越细。
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁,但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁。
那么到底锁的粒度粗好还是细好?
各有各的好,如果锁粒度比较细的话,多个线程之间的并发性就更高;如果锁粒度比较粗的话,加锁解锁的开销就更小。
我们编译器和JVM面对锁的粒度问题会有这样的一个优化,如果某个地方的代码粒度太细了,也就是进行频繁的加锁解锁,它就会进行优化,使锁的粒度变粗;如果两次加锁之间的间隔较大,中间隔的代码多,一般不会进行这个优化。
我们有些代码是不用加锁的,但是我们也给上锁了,这个时候编译器就会检查发现这个加锁好像没什么必要,就直接把锁给去掉了。比如说,我们的代码里面只有一个线程,这个时候加锁就没有意义了,编译会把我们加的锁去掉。
这个包里面提供了大量应用于线程的一些工具类,供我们在不同需求上进行应用。
callable是一个interface,同时也是一个创建线程的方式。它相当于把线程封装了一个 “返回值”,方便程序猿借助多线程的方式计算结果。而我们的Runnable虽然也可以创建线程,但是它不适合让线程计算出一个结果。
例如,创建一个线程,让这个线程计算出1+2+3+…+1000。如果此时基于Runnable来实现的话,就会比较麻烦。而callable就能解决Runnable不方便返回结果的这个问题。
(1)使用Runnable方法来实现。
public class Demo9 {
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 = 0; 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 操作, 代码复杂, 容易出错.
(2)使用Callable接口
public class Demo10 {
public static void main(String[] args) {
//通过callable来描述一个任务
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<Integer> task = new FutureTask<>(callable);
//创建线程,来完成这里的计算工作
Thread t = new Thread(task);
t.start();
// 如果线程的任务没有执行完呢, get 就会阻塞.
// 一直阻塞到, 任务完成了, 结果算出来了
try {
System.out.println(task.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
可以看到, 使用 Callable 和 FutureTask 之后, 代码简化了很多, 也不必手动写线程同步代码了
这里解释一下callable与FutureTask :
相关面试题:介绍下 Callable 是什么?
ReentrantLock是可重入锁,与synchronized一样。
ReentrantLock 的基本用法:
ReentrantLock 和 synchronized 的区别:
那么我该如何选择这两个加锁工具呢?
但在我们的实际开发中,synchronized就够用了。
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
这篇文章里面有原子类的介绍与用法:【JavaEE】常见锁策略与CAS手术刀剖析
这篇文章详细介绍了线程池:【JavaEE】多线程案例——定时器与线程池
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器。
举个例子:
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用。
public class Demo11 {
public static void main(String[] args) throws InterruptedException {
//初始化值表示可用资源有4个
Semaphore semaphore = new Semaphore(4);
//申请资源,P操作
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
//释放资源,V操作
semaphore.release();
}
}
CountDownLatch表示同时等待 N 个任务执行结束。
在现实生活中,比如说我们下载电影,如果通过多线程下载的话就可以提高下载速度了。因为我们可以把一个文件拆分成多个部分,每个线程负责下载其中的一个部分,然后等到所有的线程都下载完成了,我们的电影才算下载完毕。
CountDownLatch里面我们提供了两个方法:
下面举一个例子:跑步比赛,10个选手依次就位,枪一响同时出发,但是我们要等到所有选手都通过了终点线,才公布成绩。
线程安全与线程不安全的类:
线程不安全 | 线程安全 |
---|---|
ArrayList | Vector (不推荐使用) |
LinkedList | HashTable (不推荐使用) |
HashMap | ConcurrentHashMap |
TreeMap | StringBuffer |
HashSet | String(特殊的) |
TreeSet | |
StringBuilder |
上面的表格里我们可以看到,ArrayList是一个线程不安全的类,那么我们在多线程的情况下该如何使用ArrayList呢?
我们有以下解决方案:
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争.
缺点:
HashMap本身是线程不安全的,我们的解决方案有以下两种:
上面两种方式,我们不推荐使用HashTable,而是推荐使用ConcurrenHashMap。为什么呢?我们需要了解一下HashTable的内部构造,以及它与ConcurrenHashMap的区别。
HashTable是如何保证线程安全的呢?
HashTable保证线程安全的方式是给关键方法加锁:
那么这里,我们的ConcurrenHashMap就能够解决这个问题。ConcurrenHashMap进行操作的时候,是针对这个元素所在的链表的头节点进行加锁的,如果两个线程操作的是针对两个不同的链表上的元素,那么没有线程安全的问题,不毕加锁。
由于在hash表中,链表的数目非常多,每个链表的长度是相对短的,因此可以保证锁冲突的概率就非常小了。
与上面的hashTable相比,这里的改动影响是非常大的。通过这样一个把大锁化小锁的,化整为零的过程,就可以巧妙的使我们的锁冲突大大降低。
总结:相比于 Hashtable 做出了一系列的改进和优化:
读操作没有加锁. 目的是为了进一步降低锁冲突的概率. 为了保证读到刚修改的数据, 搭配了volatile 关键字。
这个是 Java1.7 中采取的技术. Java1.8 中已经不再使用了,简单的说就是把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁,目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争。
取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象)。将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于8 个元素)就转换成红黑树。
HashMap: 线程不安全. key 允许为 null。
Hashtable: 线程安全. 使用 synchronized 锁 Hashtable 对象, 效率较低. key 不允许为 null。
ConcurrentHashMap: 线程安全. 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用CAS 机制. 优化了扩容方式. key 不允许为 null。
写完这篇文章,也算跌跌撞撞把多线程搞完了,其中由于学艺不精,必有不尽人意和讲不清楚的地方,但是整理这些文章的目的在于自己的复习用,对象针对的更多是自己,半点墨水没有,不敢说文章能给他人有些许启发,若是有一丁点,便是幸事;若难以启发,便权当自己以后回顾时用。