目录
三、锁的使用(这里以java的synchronized为例)
一、什么是锁?
在了解什么是锁之前,我们先来看一下这样一段代码
public class Test {
//创建变量i
static int i = 0;
public static void main(String[] args) {
//我们创建两个线程,分别让i自增10000次
Thread t1 = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
i++;
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
i++;
}
});
t1.start();
t2.start();
try {
//等待两个线程执行完成
t2.join();
t1.join();
//打印i的值
System.out.println(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
按照正常的逻辑,两个线程都让i自增了10000次,i最终的结果应该是20000才对,但事实真是如此吗?我们来看一下代码的运行结果
结果并非是预期的20000,我们在执行一次看看
结果仍然不是20000,并且相对第一次执行数值又有了变化,那为什么会这样呢?因为我们没有处理线程安全的问题,那什么又是线程安全问题呢?
首先我们来仔细剖析一下 " i++" 这段代码的执行过程
1.将i的值从内存读取到cpu的寄存器上(load)
2.在cpu上进行自增的运算(operation)
3.再将运算完的结果写回内存(write)
然后我们来看一下这样一种情况
通过上图我们可以发现,线程1和线程2从内存中读到的i的值都为1,两个线程完成自增操作后结果都为2,线程1将2写回内存,线程2也将2写回内存,由此导致两次自增只产生一次自增的效果,这种由于多线程而产生的结果不符合预期的情况就是线程安全问题。上面的代码会产生与预期不符的执行结果,也是因为在运行中多次出现了这种线程安全问题。
那如何解决这种线程安全问题呢?
首先我们要知道的是操作系统对线程的调度是随机的,这就导致了我们不知道哪个线程的哪个指令会先被执行,但我们如果能够线程保证在执行一种操作时,其他要执行相同操作的线程都得等待正在执行该操作的线程执行完毕才能够继续进行该操作,那是不是就没有上述问题了,这里我们画个图理解一下
从图中我们发现线程1在执行自增操作的时候,线程2什么也没用执行,反之线程2在自增时,线程1什么也没执行,从而使每次自增都能使内存中i的值加1,也就解决了前面的线程安全问题,而锁就对这种功能的实现。首先,锁会对某个对象进行加锁,只有成功对该对象加锁的线程才能继续执行任务,其他要对这个对象加锁的线程只有等到加到锁的线程将锁释放才能继续尝试进行加锁,加到锁之后才能继续执行下面的任务。
在Java中加锁通常使用synchronized关键字,下面是对前面的自增代码进行加锁之后的代码
public class Test {
//创建变量i
static int i = 0;
static Object lock = new Object();
public static void main(String[] args) {
//我们创建两个线程,分别让i自增10000次
Thread t1 = new Thread(() -> {
//对lock对象加锁,没有加到锁进行阻塞等待,等到锁被释放再次尝试加锁
synchronized (lock) {
for (int j = 0; j < 10000; j++) {
i++;
}
}//大括号中的内容执行完后自动将锁释放
});
Thread t2 = new Thread(() -> {
synchronized (lock) {
for (int j = 0; j < 10000; j++) {
i++;
}
}
});
t1.start();
t2.start();
try {
//等待两个线程执行完成
t2.join();
t1.join();
//打印i的值
System.out.println(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
运行结果:
根据结果我们可以发现加锁完美的解决了线程安全的问题,但细心的同学可能会发现加锁之后不就跟单线程一样了吗,因为在加锁的代码块中每一时刻只能有一个线程执行,其他线程都在等待,那是不是使用多线程就没有意义了呢,其实不然,因为各线程之间不仅是随机调度,同时还是抢占式执行的,多个线程去抢占cpu资源肯定比一个线程抢占的更多,并且一个线程通常不会全部代码都加锁,所以在这种情况下使用多线程还是能够起到优化效率的作用的,但要注意的是,线程和锁的创建都是具有一定开销的,所以不建议开启多个线程去执行一些比较简单的任务,引入线程安全问题的同时,还会带来许多不必要的开销。
二、锁的特性
互斥性
前面我们了解过,当一个线程加到锁后,其他线程要想获得这把锁,就得进行阻塞等待,直到锁被释放,才能继续尝试进行加锁,这就是锁的互斥性。
可重入性
一个线程在成功加到锁后,如果再次去获得这把锁,也是可以的,这就是锁的可重入性,但并不是所有锁都具有这样的操作,只有被设计为可重入锁才具有这种特性,Java的synchronized就是一把可重入锁
(注意:同一把锁通常可以理解为对同一个对象加的锁)
三、锁的使用(这里以java的synchronized为例)
锁的使用一般有以下三种方法
修饰实例方法,对该实例进行加锁
public synchronized void test02(){
}
修饰代码块,指定对象进行加锁
指定实例对象进行加锁
public void test02(){
synchronized (this){
}
指定类对象进行加锁
public void test02(){
synchronized (Test.class){
}
}
修饰静态方法,针对当前类对象加锁
public synchronized void test02(){
}
只有对同一个对象或者类对象或者实例对象进行加锁的线程之间才会体现锁的互斥性,而对不同对象加锁的线程之间是没有影响的
四、死锁
什么是死锁
通过加锁我们可以解决一些线程安全问题,但是这个世界并不存在绝对完美的事物,所有事物都会具有一定的缺陷,锁也一样,加锁不仅会增加我们的系统开销,还会给我们带来一个新的问题,那就是"死锁"。死锁又是什么呢?我们先来看一下下面这串代码
public static void test01(){
//创建对象A和B
Object A = new Object();
Object B = new Object();
//创建线程1和线程2
Thread t1 = new Thread(() -> {
synchronized (A){
//执行一段循环,以免加到两把锁
for (int i = 0; i < 10000; i++) {
}
synchronized (B){
}
}
});
Thread t2 = new Thread(() -> {
synchronized (B){
//执行一段循环,以免加到两把锁
for (int i = 0; i < 10000; i++) {
}
synchronized (A){
}
}
});
t1.start();
t2.start();
while (true){
System.out.println("线程1的状态:" + t1.getState().toString() +" | 线程2的状态:" + t2.getState().toString() );
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
运行结果
通过运行结果我们发现,线程1和线程2都是在由RUNNABLE状态转为BLOCKED状态之后就一直处于BLOCKED状态,那为什么会这样呢,我们先通过一个流程图来看一看这两个线程的具体是怎么执行的
通过流程图可以发现,两个线程都在等待对方释放锁,但两个线程释放锁的条件都是要获得对方的锁,这就像是两个人在吃饺子,桌子上有一瓶醋和一瓶酱油,一个人拿到了酱油,另一个人则拿到了醋,突然其中一个人想试试对方手里的醋,而另一个人也想试试他手里的酱油,其中一个人说:你把醋给我,我就把就酱油给你,而另一个人说:你把酱油给我,我就把醋给你。就这样两个人直接僵在了这里,这两个线程也是处于这种情况,而这种由于锁造成的线程之间相互死等的状态就是我们通常所说的死锁
死锁形成的条件
通过上述场景我们可以总结出来,死锁的形成通常有以下四个必要条件
1.互斥性,一个线程或得锁时另一个线程要想获得就得进行等待
2.不可抢占,线程1在获得锁A后,线程2要想获得锁A就得等线程1将锁A释放,不能强行获得
3.请求和保持,线程1在请求获得锁B时,锁A仍然保持加锁状态
4.循环等待,线程1要获得锁B后才能释放锁A,而线程2要获得锁A才能释放锁B,线程之间具有循环的等待关系。
如何预防死锁
通过上述死锁形成的条件我们可以发现,前三条都是锁的特性,无法做出调整,所以我们要想预防死锁就得解决循环等待的问题。解决循环等待,通常可以从调整加锁的顺序入手,还是以吃饺子为例,桌子上还是一瓶醋一瓶酱油,但这次有了一个新的规定,只能先拿酱油后拿醋,酱油和醋必须同时放回,这就导致了先拿到酱油的人可以连者拿到酱油和醋,而另一个人只能等待先拿到的人使用完,才能去拿酱油和醋。这种新的规定就避免了前面循环等待的僵持情况,并且每个人都能顺利的拿到酱油和醋。接下来我们对前面的代码也进行一下顺序调整
public static void test01(){
//创建对象A和B
Object A = new Object();
Object B = new Object();
//创建线程1和线程2
Thread t1 = new Thread(() -> {
synchronized (A){
//执行一段循环,以免加到两把锁
for (int i = 0; i < 10000; i++) {
}
synchronized (B){
}
}
});
Thread t2 = new Thread(() -> {
synchronized (A){
//执行一段循环,以免加到两把锁
for (int i = 0; i < 10000; i++) {
}
synchronized (B){
}
}
});
t1.start();
t2.start();
while (true){
System.out.println("线程1的状态:" + t1.getState().toString() +" | 线程2的状态:" + t2.getState().toString() );
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
运行结果
这次两个线程就能够顺利执行完毕了
五、java标准库中线程安全的类
在Java标准库中,有很多线程安全的类,例如
StringBuffer
ConcurrentHashMap
String
HashTable
这些类通常都是通过加锁实现线程安全的,但也有部分类并没有加锁也实现了线程安全,就比如String,它是因为不可变的特性,所以多线程使用时也是安全的
在java标准库中同样还有很多线程不安全的类,例如
StringBuider
LinkedList
ArrayList
HashMap
HashSet
TreeMap
在多线程环境使用这些类时,应该时刻注意他们带来的线程安全隐患