1 基本概念2 线程不安全的原因
2.1 原子性2.2 内存可见性2.3 指令重排序 3 线程不安全问题解决方式
3.1 synchronized3.2 锁的具体细节3.3 volatile 关键字 4 总结
1 基本概念 线程安全问题是多线程并发编程所涉及到的最重要,也是最复杂的问题,能够正确编写多线程代码的重要前提就是必须要深刻理解线程安全的相关内容;
线程不安全: 多线程并发执行某个代码时,产生了逻辑上的错误,就是"线程不安全";
线程安全: 多线程并发执行某个代码,没有逻辑上的错误,就是"线程安全".
那么什么是原子性呢?
我们可以把一段代码想象成是一个 ATM 自动存储机,每个线程就是要进入这个 ATM 的人,那么小明进入之后进行存取的时候,小红是不可能进入的,否则就可能会出现安全问题; 如果小红在小明存钱期间进去了,这就说明其不具备原子性. 那么解决这个问题的方法就是 ATM 门上会有个落锁装置,小明进去之后 ATM 就会自动锁住,其他人是进不去的,这样就保证了这段代码的原子性.有时候也会把这个现象叫做同步互斥,表示操作是互相排斥的.
这里有一个注意点:
问题: 一条 Java 语句是原子的么???
一条 Java 语句不一定是原子的,也不一定只是一条指令,例如 i++,其实这是由三个步骤构成的:
从内存把数据读到 CPU;进行数据更新;把数据写回到 CPU.
通过上面的例子可以看出来,如果一段代码不保证其原子性,会给线程带来安全问题,就好比一个线程正在对一个变量进行操作,中途有其他的线程插进来了,如果这个操作被打断了,结果可能会产生错误!!
如果是一个线程修改一个变量 => 线程安全;如果多个线程尝试读取同一个变量 => 线程安全;如果多个线程尝试修改不同的变量 => 线程安全;如果有多个线程,其中一个线程读数据,一个线程修改数据,此时可能会导致线程不安全. 2.2 内存可见性
为了提高效率, JVM 在执行过程中,会尽可能的将数据在内存中工作,但这样会造成一个问题,共享变量在多线程之间不能及时看到改变,这个就是可见性问题.
例如一段代码是这样的:
a) 去水房接水;b) 去菜鸟驿站取快递;c) 去菜鸟驿站寄快递.
如果是单线程情况下, CPU / JVM 指令集会对其进行优化,如按照 a -> b -> c 的顺序执行, 这样的话就可以少去一次菜鸟驿站,这样的操作就是指令重排序. Java 的编译器在编译代码时,会对指令进行优化,调整指令的优化顺序,保证在原有逻辑不变的情况下,提高程序的运行效率.
但是指令重排序也可能会带来一些困扰:
例如上面的例子单线程的情况是没有问题的,优化也是正确的,但是在多线程的场景下就可能会出现问题, 例如在菜鸟驿站寄快递过程中,你的舍友也来到了菜鸟驿站并且帮你取了快递,这时候可能就会出现逻辑错误.
抢占式执行(这个是操作系统内核来实习的);将代码改为非原子(加锁,这是最常见也是使用范围最广的操作);多个线程同时修改同一个变量(不一定行不行,要看具体需求). 3.1 synchronized
synchronized 的底层是使用操作系统的 mutex lock 来实现的.
a) 当线程释放锁时, JMM( Java 的内存模型) 会把该线程对应的工作内存中的共享变量刷新到主内存中;
b) 当线程获取锁时, JMM 会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量,
c) synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的情况;
d) 同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入.
锁是啥??? 就是和日常生活中的锁类似.
锁的特点: 锁是互斥的,同一时刻只有一个线程能获取到锁,其他线程如果也尝试获取锁,就会发生阻塞等待,一直等到刚才的线程释放锁,此时剩下的线程再重新竞争锁.
例如:
public class ThreadDemo {
static class Counter {
public int count = 0;
synchronized public void increase() {
count++;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
t2.start();
t1.join();
t2.join();
//两个线程各自自增5W次,最终预期结果应该是10W
System.out.println(counter.count);
}
}
代码解读:
进入 increase 方法之前会尝试加锁, increase 方法执行之后会自动解锁;加锁 / 解锁 都是由一个关键字包办了,这样的好处是避免出现忘记解锁的情况;尝试加锁的时候不一定能立刻就成功,如果发现当前的锁已经被占了,该代码就会一直阻塞等待,一直等到之前的线程释放锁,才可能会获取到这个锁.
运行结果:
工作流程: 多个线程同时获取同一把锁就只有一个线程能获取到,其他线程会阻塞等待;底层实现: 每个对象有个对象头,对象头里有个锁标记;加锁的时候一定要明确到底是给哪个对象加锁. 3.3 volatile 关键字
作用: 保持内存可见性,禁止编译器进行某种场景的优化(一个线程读,一个线程在写,修改对于读线程来说可能没生效).
注意点: 仅限一个线程进行读或者写,两个线程都在使用的话不适用,其解决的是内存可见性,并不是原子性; 加了 volatile 之后,对这个内存的读取操作肯定是从内存中读取; 不加 volatile 的时候,读取操作可能是不从内存读取了而是直接读取 GPU 上上次读到的旧的值.
例如:
public class ThreadDemo {
static class Counter {
public int flag = 0;
public volatile int flags = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread() {
@Override
public void run() {
while (counter.flags == 0) {
}
System.out.println("循环结束!");
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数: ");
counter.flags = scanner.nextInt();
}
};
t2.start();
}
}
运行结果:
线程不安全的场景
一个线程读,一个线程写: volatile 来解决;两个线程写: synchronized 加锁解决.



