线程安全指的是当多个线程同时访问一个共享的资源时,不会出现不确定的结果。这意味着无论并发线程的调度顺序如何,程序都能够按照设计的预期来运行,而不会产生竞态条件(race condition)或其他并发问题。
请看如下代码:
我们用两个线程分别让count++ 5w次,最后我们打印count,理论得到的结果是10w
public class ThreadDemo13 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
运行代码:
我们发现结果与我们预期的并不相同。
其实 count++ 是由三个cpu指令来完成的
由于线程之间是并发执行的,每个线程执行到任何一条指令后,都可能从cpu上调度走,而且去执行其他线程,于是当 t1 线程和 t2 线程并发执行时,会存在以下情况。
可以看到 这种情况两次 count++ 实际上只让count+1了,并且实际情况中 t1 的load和add之间又能有更多的指令,那样则会导致 多个count++ 只会令count+1 ,所以导致了结果与预期不符合
这类问题就称为线程不安全问题。
从上面的示例我们可以发现,导致线程不安全的原因是,count++ 这三个指令不是整体执行的,于是我们要解决这个问题就可以想办法使这三个指令为一个整体
在Java中我们可以通过加锁的方式来保证线程安全,锁具有 “互斥” “排他” 的特性
在Java中,加锁的方式有很多种,最主要的方式,是通过 synchronized 关键字
语法:
synchronized(锁对象) {
//要加锁的代码
}
加锁的时候,需要“锁对象”,如果一个线程用一个锁对象加上锁以后,其他线程也尝试用这个锁对象来加锁,就会产生阻塞(BLOCKED),直到前一个对象释放锁
锁对象是一个Object对象
我们给上述ThreadDemo13中 t1, t2 中的count++加上锁:
public class ThreadDemo13 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
//随便创建一个对象,作为锁对象,因为所有类默认继承于Object类
//所以任意一个类的对象都可以作为锁对象
Object locker = new Object();
Thread t1 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
此时我们再次运行代码:
发现结果正确, 这是因为我们给 t1, t2 中的count都加上了同一个锁,运行代码时, t1 中的count++ 没有执行完时,t2中的 count++ 拿不到锁,就不会执行,同理,t2 中的count++ 没有执行完时,t1中的 count++ 也拿不到锁,也就不会执行。所以就保证了线程安全。
这里我们可以这样理解加锁操作:给代码加锁,就是规定这段代码必须拿到对应的锁才能执行,如果另一个线程中也有代码加了这把锁(即相同的锁对象),同样这段代码也必须拿到这个锁才能执行,但是这个锁只有一个,所以同一时间只能执行一段代码,另一段代码只能等上一段代码执行完,把锁释放了,才能拿到锁进而执行
注意:
synchronized 还有几种写法
1. 下面这个代码是否是线程安全的?
class Test {
public static int count = 0;
public void add() {
synchronized (this) {
count++;
}
}
}
public class ThreadDemo14 {
public static void main(String[] args) throws InterruptedException {
Test t = new Test();
Thread t1 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
t.add();
}
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
t.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + t.count);
}
}
注意this指的是当前对象,我们发现 调用add的都是t,所以, t1, t2 中 都是通过 t 来加锁,所以存在锁竞争,这段代码是线程安全的:
2. 锁可以加在方法上面
class Test1 {
public static int count = 0;
synchronized public void add() {
count++;
}
}
public class ThreadDemo15 {
public static void main(String[] args) throws InterruptedException {
Test1 t = new Test1();
Thread t1 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
t.add();
}
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
t.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + t.count);
}
}
这种写法与上面效果相同,都是通过当前对象加锁
下列代码能否正常打印Ting?
public class ThreadDemo16 {
public static void main(String[] args) {
Object locker = new Object();
Thread t = new Thread(() -> {
synchronized (locker) { //1
synchronized (locker) { //2
System.out.println("Ting");
}// 3
}// 4
});
t.start();
}
}
答案是可以的:
解释:在1位置,第一次使用locker加锁,很明显这里是可以加上的,在2位置,这里也在尝试使用locker进行加锁,按照我们上面的理解,这里的锁是加不上的,但是最后却输出了Ting,这里是因为这两次加锁是同一个线程在进行,这种操作是允许的,这个特性称为可重入,这是Java开发者为了防止出现死锁而设计的,
注意:这种写法在1位置才会加锁,在2 位置时,不会真的加锁,在3位置也不会释放锁,在4位置才会释放锁 ,对于可重入锁,内部会有一个加锁次数的计数器,当加锁时计数器为0 才会加锁,每“加一次锁”计数器+1,而每出一个“}”计数器-1,为0时才释放锁
1. 一个线程,一把锁
如同上面所讲的,如果锁是不可重入锁,并且一个线程,用这把锁加锁两次就会出现死锁
2. 两个线程 两把锁
线程 1 获取到锁 A
线程 2 获取到锁 B
在这种情况下 ,1尝试获取 B, 2尝试获取 A
示例代码 :
public class ThreadDemo17 {
public static void main(String[] args) {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
try {
//等 t2 拿到B
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (B) {
System.out.println("t1拿到了两把锁");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (B) {
try {
//等 t1 拿到A
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (A) {
System.out.println("t2拿到了两把锁");
}
}
});
t1.start();
t2.start();
}
}
3. N个线程M把锁
类似于上面的两个线程两把锁的问题,
下面简单画个图演示这种情况:
这种情况下,每个线程都在等待左边的锁被释放,形成一个死锁
解决:对每把锁都进行编号,规定每个线程都必须先获取编号小的锁,再获取编号大的锁,于是,线程1在获取到锁A前是不会获取锁F的,所以就避免了上述情况。
在Java中,多线程共享内存可能会导致内存可见性问题,从而引起线程安全问题。简单来说,内存可见性是指:当一个线程修改了共享变量的值时,这个新值可能不会立即被其他线程所看到
示例代码:
public class ThreadDemo18 {
public static int flag = 0;
public static void main(String[] args) {
int a = 0;
Thread t1 = new Thread(() -> {
while(flag == 0) {
}
System.out.println("t1线程结束");
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("把flag置为1");
flag = 1;
});
t1.start();
t2.start();
}
}
当我们运行代码发现:
flag被置为1后 t1线程并没有结束
这里我们主要查看这段代码:
这段代码的核心指令只有两条
1. 读取内存中flag的值到cpu寄存器里
2. 拿寄存器里的值和 0 比较
在上述循环中的循环速度是非常快的, 一秒钟可能就运行了几亿次,在这个执行过程中,1操作每次读取的结果都是一样的,并且我们知道,内存的读写速度,相对于2操作的比较速度,是慢得多的,在这个循环中,九成九的时间都在执行1操作,并且运行了很多次(几亿甚至上百亿),读取的值都没有变化,此时JVM 就可能做出优化:不再执行 1 操作,直接用之前寄存器中的值和0做比较。当后面 flag的值变为1后,t1 线程中寄存器中存的值还是0,所以循环不会结束。
我们可以在while中加一个sleep,让循环变慢:
public class ThreadDemo18 {
public static int flag = 0;
public static void main(String[] args) {
int a = 0;
Thread t1 = new Thread(() -> {
while(flag == 0) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("t1线程结束");
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("把flag置为1");
flag = 1;
});
t1.start();
t2.start();
}
}
我们发现循环可以正常结束。
这是因为不加sleep时,一秒钟循环几亿次,操作 1 的整体开销占比是非常大的,优化的迫切程度就更高;加了sleep后一秒循环1000次,操作 1 的整体开销占比就小很多了,优化的迫切程度也就每那么高。
Java中提供了 volatile 关键字,可以使上述优化被关闭
public class ThreadDemo18 {
volatile public static int flag = 0;
public static void main(String[] args) {
int a = 0;
Thread t1 = new Thread(() -> {
while(flag == 0) {
}
System.out.println("t1线程结束");
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("把flag置为1");
flag = 1;
});
t1.start();
t2.start();
}
}
运行结果:
volatile 有两个功能
1. 保证内存可见性
2. 防止指令重排序
Java中的线程饥饿(Thread Starvation),是指某个或某些线程无法获取到所需的CPU时间或其它系统资源,从而陷入长时间的等待状态,无法继续正常执行。这种情况可能会导致程序性能下降、响应时间延长,甚至出现死锁等严重问题。
线程饥饿通常是由于以下几个原因引起的:
CPU资源被占用:当有一个或多个线程占用了大量的CPU资源时,其他线程可能无法获得足够的CPU时间,从而无法正常执行。
长时间等待资源:当某个线程需要等待某个资源(如锁、I/O操作等)时,如果该资源一直被其他线程占用,那么该线程可能会长时间等待,从而导致线程饥饿。
示例代码:
public class ThreadDemo19 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker) {
while(true) {
//模拟长时间占用锁
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker) {
System.out.println("执行了t2");
}
});
t1.start();
//确保t1先拿到locker
Thread.sleep(100);
t2.start();
}
}
线程优先级不足:当有多个线程同时竞争某个资源时,如果优先级较低的线程一直无法获得该资源,那么它们可能会陷入长时间的等待状态。
例如:现有 1,2,3 三个线程,线程1 的优先级更高,现在线程1 拿到了锁,但是现在某个条件不满足,线程1无法执行,所以线程1 由把锁释放了,但是线程 1 释放锁之后 任然会参与到锁竞争中,又由于 线程1的优先级高于线程2和线程3,所以任然是线程1拿到锁,于是导致了死锁。这种情况下我们可以使用 wait/notify 解决 让线程1 在条件满足时 再尝试获取锁
wait()方法:
notify()方法:
示例:
public class ThreadDemo20 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker) {
try {
System.out.println("wait之前");
//wait必须在synchronized内部,因为要释放锁的前提是得加上锁
locker.wait();
System.out.println("wait之后");
} catch (InterruptedException e) {
//wait 和 sleep join都是一类的可能会被提前唤醒,需要捕获异常
e.printStackTrace();
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker) {
System.out.println("notify之前");
//Java特别规定notify也必须在synchronized内部
locker.notify();
System.out.println("notify之后");
}
});
t1.start();
Thread.sleep(1000);
t2.start();
}
}
执行过程:
t1 执行后会立刻拿到锁,并且打印 “wait之前” 然后进入wait方法 (释放锁,阻塞等待),然后等待1秒,t2开始执行,拿到锁 打印 “notify”之前 ,然后执行 notify,让 t1 停止阻塞,重新参与锁竞争
注意:wait()可以设置最大等待时间,具体规则和join相同