文章目录
一、synchronized关键字
1.synchronized的用法
- 把synchronized加到普通方法上
synchronized public void increase(){
count++;
}
- 把synchronzied加到代码块上
public void increase(){
synchronized (this){
count++;
}
}
- 把synchronized加到静态方法上
这里所谓的“静态方法”,更严谨的叫法应该叫做“类方法”;而我们平时所说的“普通方法”,更严谨的教法其实是叫“实例方法”。
public static void func(){
synchronized(Counter.class){
}
}
synchronized关键字也叫做监控器锁(monitor lock),有时候的代码异常信息,可能会提到monitor这个词。
2.synchronized 的特性
(1)互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.
- 进入 synchronized 修饰的代码块, 相当于 加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
上面提到过一下,synchronized用的锁是存在Java对象头里的。这个对象头如何理解呢?
那阻塞等待又是什么意思呢?
理解 “阻塞等待”:
针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待,一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁。
【注意】
- 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 “唤醒”. 这也就是操作系统线程调度的一部分工作.
- 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
synchronized的底层是使用操作系统的mutex lock实现的.
(2)刷新内存
synchronized 的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作的内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
所以 synchronized 也能保证内存可见性,具体在后面介绍volatile的时候讲解。
(3)可重入
直观来说,同一个线程针对同一个锁,连续加锁两次,如果出现了死锁,就是不可重入,如果不会死锁,就是可重入
想理解什么是可重入,我们先来理解什么是死锁,这里举一个例子:
上面的这种情况就是一个死锁的例子了。
在这里思考一个问题:如果我们的代码连续锁两次会怎么样?
//连续锁两次
synchronized public void increase(){
synchronized (this){
count++;
}
}
实际上,这种代码在实际开发中,一不留神可能就会写出来,那么如果代码真的死锁了,程序猿修bug此不是修死了?
于是,实现JVM的大佬们显然也注意到了这一点,就把synchronized实现成了可重入锁。对于可重入锁来说,上述的操作是不会导致死锁的。
那么,可重入锁是如何作用的呢?
可重入锁的内部会记录当前锁被哪个线程占用的,同时也会记录一个“加锁次数”count。 当线程a针对锁进行第一次加锁的时候,显然能够加锁成功,那么锁内部就记录了当前占用着的是a,同时加锁次数count=1。 后续如果我们再a对锁进行加锁的时候,此时就不是针对加锁,而是单纯的把计数给自增,加锁次数变为2。 如果后续再解锁的时候,先把计数-1,当锁的计数减到0的时候,就真的解锁了
可重入锁的意义是降低了程序猿的负担,也就是降低了使用成本,提高了开发效率。然而,也带来了代价,那就是我们的程序中需要有更多的开销,我们需要去维护锁属于哪个线程,并且需要加减计数,降低了运行效率。
二.死锁
1.死锁的场景
(1)一个线程一把锁,这个就是上面讲的例子,这里不再重复
(2)两个线程两把锁
这里是如何导致死锁的呢?我们举一个例子来理解,两个外卖小哥互不相让,那么在这种情况下就会导致死锁了。
(3)N个线程,M把锁
这里举一个经典的问题:哲学家就餐问题来理解。
这个问题,其实跟上面第二种情况本质不变,只是情况变得更加复杂了,这里的筷子就表示着一把锁,哲学家就是一个线程,五个线程五把锁。
2.死锁的四个必要条件
上面,我们只是说了死锁的几个场景,可是它为什么就是死锁呢?因为它们都满足以下死锁的四个必要条件。
- 互斥使用,一个锁被一个线程占用了之后,其他线程就占用不了(锁的本质,保证原子性)
- 不可抢占,一个锁被一个线程占用了之后,其他的线程不能把这个锁抢走(不能挖墙脚)
- 请求和保持,当一个线程占据了多把锁之后,除非显式的释放锁,否则这些锁始终都是该线程持有的
- 环路等待,等待关系成环了。A等B,B等C,C又在等A。
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。以上前三个特点是锁本身的特点,在实际开发当中,如果要避免死锁,还是得从第4个条件入手,也就是不让它进入环路等待。
3.如何破坏死锁
刚刚说了,破坏死锁最好就是从第四点入手,因为前3个都属锁本身的特点。那么破坏死锁的关键其实很简单,就是两个字——规则。
但是在实际开发中,很少会出现一个线程需要锁里再套锁这样的情况
synchronized(a){
synchronized(b){
synchronized(c){
}
}
}
如果不嵌套使用锁,也就没那么容易死锁,也就是我们从实际使用上就规避了这种情况的产生。如果实在不得不使用这样的场景,那么一定要预定好加锁的顺序,所有的线程都按照a->b->c…这样的顺序加锁,避免出现环路等待。
4.Java 标准库中的线程安全类
谈到线程安全,就不得不提以下Java标准库中的线程安全类。Java中有很多现成的类,有些类是线程安全的,有些是不安全的。在多线程下,如果使用线程不安全的类,就需要谨慎了,因为这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.
线程不安全 | 线程安全 |
---|---|
ArrayList | Vector (不推荐使用) |
LinkedList | HashTable (不推荐使用) |
HashMap | ConcurrentHashMap |
TreeMap | StringBuffer |
HashSet | String(特殊的) |
TreeSet | |
StringBuilder |
线程安全的类使用了一些锁机制来控制,也就是说它在一些关键的方法上面都有synchronized,有了这个操作,就可以保证在多线程环境下,修改同一个对象,不会发生什么差错。像线程不安全的类这些在单线程环境下使用没多大问题,但在多线程下就会有大问题。
线程安全类里面有一个String类,这个是特殊的,因为这个东西没有synchronized,它属于安全类是因为它是不可变对象,没法在线程中修改同一个String,也就是说String虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的。
【注意】
不可变对象和常量/final之间没有必然联系,不可变对象的意思是没有提供public的修改属性操作,而String源代码中的final表示String不能被继承。
三、volatile 关键字
1.volatile 能保证内存可见性
这个内存可见性上篇介绍多线程的文章详细介绍过。
【JavaEE】线程安全与线程不安全问题手术刀剖析
volatile这个关键会禁止编译器优化,保证内存可见性。
2.JMM(Java内存模型)
这个先介绍一下这个JMM,Java Memory Moder(Java内存模型)。这个东西是啥呢?其实这个东西就是把我们之前所讲的硬件结构,在Java中用专门的术语又重新抽象封装了一遍。比如:
那为什么要这样做呢?因为Java作为一个跨平台的编程语言,要把硬件的细节封装起来,让程序猿感觉不到CPU,内存等硬件设备的存在。
上面的哪个图还是比较粗糙一点,实际上,在我们的内存与存储之间还有一个缓冲空间,我们称为cache。
3. volatile 不保证原子性
volatile只是保证可见性,不保证原子性。也就是说,volatile只是处理一个线程读一个线程写的情况,而synchronized都能处理。
有同学看到这里可能会想,既然如此,那么到时候直接无脑用synchronized就可以。
实际上,我们也不能无脑用,因为synchronized的使用是要付出代价的,代价就是一旦使用synchronized就很容易导致线程阻塞,一旦线程阻塞(放弃CPU),下次回到CPU,这个时间就不可控了,如果调度不回来,对应的任务执行时间,自然就会被拖慢。严重一点说,一旦使用了synchronized,这个代码大概率与“高性能”无缘了。
而volatile不会导致线程阻塞。
四、wait 和 notify
1.wait 和 notify的使用
wait 与notify是用来处理线程调度随机性的问题的,我们在程序运行的时候不喜欢随机性,更倾向于它们有一个固定的顺序来运行。因此,我们就可以使用wait和notify。
wait和notify都是Object对象方法,调用wait方法的线程,就会陷入阻塞,阻塞到有其他线程通过notify方法来通知。
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println("wait前:");
object.wait();
System.out.println("wait后:");
}
为什么会发生这样的情况呢?
是这样的,调用wait方法后,wait内部会做3件事:
- 先释放锁
- 等待其他线程通知
- 收到通知之后,重新获取锁,并继续往下执行
因此,想要使用wait和notify,就得搭配synchronized。
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object){
System.out.println("wait前:");
object.wait();
System.out.println("wait后:");
}
}
private static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
synchronized (locker){
//1.进行wait
System.out.println("wait之前");
try {
locker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
Thread.sleep(3000);
Thread t2 = new Thread(()->{
//2.进行notify
synchronized (locker){
System.out.println("notify之前");
locker.notify();
System.out.println("notify之后");
}
});
t2.start();
}
可以看到,这里在notify进行唤醒之后的打印顺序,当遇到wait的时候,进入阻塞,然后直到遇到notify,被唤醒了,才再次执行程序。
因此,我们可以概括出wait与notify的使用场景。
这里还有一个notifyAll,区别如下:
说了这么多,我们来总结一下wait与notify方法。
- wait 做的事情:
- 使当前执行代码的线程进行等待. (把线程放到等待队列中)
- 释放当前的锁
- 满足一定条件时被唤醒, 重新尝试获取这个锁.
- wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常
- wait 结束等待的条件:
- 其他线程调用该对象的 notify 方法
- wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
- 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常
- notify 方法是唤醒等待的线程
- 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁
- 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 “先来后到”).
- 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁
- notifyAll()方法
- notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程
2. wait 和 sleep 的对比(面试题)
其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间。
总结如下:
- wait 需要搭配 synchronized 使用. sleep 不需要.
- wait 是 Object 的方法 sleep 是 Thread 的静态方法.
最近老是传出裁员的信息,搞到我的定力稍稍受损,但最终还是挺过来,当自己没拥有的时候,说什么都没用,看什么都没用,连被裁,你都得首先有这个实力。