多线程在多进程的基础上更好解决了并发问题,但由于一个进程内的多个线程是资源共享的,就会出现多个线程在并发执行的时候造成内存中数据的混乱。
举一个例子:
- class Counter {
- public int count;
-
- public void add() {
- count++;
- }
- }
-
- public class ThreadDemo1 {
- public static void main(String[] args) {
- Counter counter = new Counter();
- Thread t1 = new Thread(() -> {
- for (int i = 0; i < 50000; i++) {
- counter.add();
- }
- });
- Thread t2 = new Thread(() -> {
- for (int i = 0; i < 50000; i++) {
- counter.add();
- }
- });
- t1.start();
- t2.start();
-
- try {
- t1.join();
- t2.join();
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
-
- System.out.println("count = " + counter.count);
- }
- }
这里定义两个线程实例对象t1,t2(执行的任务分别是循环调用50000次add操作),add方法的作用是对成员变量count进行++,我们设想两个线程并发执行,结果输出的count值应该为100000,但是并不是,这就是一个典型的线程安全问题!!!
首先我们需要明确add操作主要干了什么?
可以看出 add操作分为了三个指令,没进行一次++CPU就会执行三条指令(其中这三条指令是串行的)
load :把内存中的值加载到CPU的寄存器上
add :在CPU的寄存器上进行++操作
save: 把计算之后的值再存到内存当中
那为什么会在进行add操作的时候会出现输出结果小于100000呢?
我们知道两个线程在并发编程的时候是抢占式执行的(谁先抢到CPU资源谁就会被优先调度,此时另一个线程就会阻塞),此时两个线程中的add操作锁所对应的指令就会出现很多种情况,就会造成计算结果出错。
这里举出了两个例子,两个线程的所对应的三条指令顺序都不一样(是因为线程1,2抢占式执行当某一个线程执行其中一条指令时,另一个线程被调度时会优先执行另一个线程的指令,此时之前的线程就会被阻塞),第一种情况add了两次正常输出2,而第二种情况只被add了一次输出1;
什么是线程安全:线程安全确切的定义十分复杂,所以我们一般认为,如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的;
上述例子若改成单线程:
- class Counter2 {
- public int count;
-
- public void add() {
- count++;
- }
- }
-
- public class Test {
- public static void main(String[] args) {
- Counter2 counter2 = new Counter2();
- for (int i = 0; i < 50000; i++) {
- counter2.add();
- }
- for (int i = 0; i < 50000; i++) {
- counter2.add();
- }
- System.out.println("count = " + counter2.count);
- }
- }
此时输出正确,所以我们可以认为上述输出结果不是预期的那种多线程代码是线程不安全的。
1.抢占式执行,随机调度(根本原因,由操作系统内核决定,无法改变)
2.因为单个进程下的多个线程是资源共享的,所以多个线程修改同一个变量时会线程不安全
多个线程修改不同的变量 没事
一个线程修改同一个变量 没事
多个线程读取同一个变量 没事
3.原子性
如果修改操作是原子性的,就不会有线程安全问题
如果修改操作是非原子性的就很大概率出现线程安全问题(上述示例的add操作就是非原子性 的,一个add操作分成了三个指令去执行)
所以我们去避免线程安全的主要手段就是将非原子性的操作变成原子性---->加锁
4.内存可见性问题
当一个线程在读取数据,一个线程在修改数据时就会出现线程安全问题(也就是常说的脏读问 题)
5.指令重排序(本质上是代码出现了bug)
针对线程安全问题,我们往往常用的手段就是把非原子性操作变成原子性操作,此时就需要用到synchronized关键字(该关键字的作用就是对对象加锁)
针对上述的代码进行修改:
- class Counter {
- public int count;
-
- synchronized public void add() {
- count++;
- }
- }
-
- public class ThreadDemo1 {
- public static void main(String[] args) {
- Counter counter = new Counter();
- Thread t1 = new Thread(() -> {
- for (int i = 0; i < 50000; i++) {
- counter.add();
- }
- });
- Thread t2 = new Thread(() -> {
- for (int i = 0; i < 50000; i++) {
- counter.add();
- }
- });
- t1.start();
- t2.start();
-
- try {
- t1.join();
- t2.join();
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
-
- System.out.println("count = " + counter.count);
- }
- }
此时输出结果为我们预期的100000;
在多线程环境下,当一个线程在被调度时(拿上述例子解释:当线程t1调用add方法进行conut++操作时,因为线程是抢占式执行的,此时线程t2想要调用add方法时发现线程t1正在执行add方法,线程t2就会发生线程阻塞,等待线程t1完全执行完时线程t2才可以进行add操作),另一个线程就会阻塞等待,直到当前线程执行完毕,另一个线程才可以执行。
当有一个滑稽老铁在上厕所时(厕所相当于对象,滑稽老铁相当于线程),此时门就会上锁(这个锁的作用就相当于synchronized的作用),其余的滑稽老铁必须等待当前的滑稽老铁上完厕所,他们才可以使用这个厕所(相当于此时其他线程是阻塞等待的)。
1.互斥
指的是当一个线程执行到synchronized对象中时,另一个对象执行到这个对象时就会阻塞等待;
进入 synchronized 修饰的代码块, 相当于 加锁
当同一个线程对一个对象多次加锁时,并不会出现问题时就称该锁为可重入,否则就称该锁不可重入(此时会造成死锁的问题)
synchronized可以修饰方法(包括实例方法和静态方法)也可以修饰代码块
修饰实例方法:锁的是synchronized对象;
修饰静态方法:锁的是Counter类对象;
修饰代码块:锁的是当前对象;
其中的this可以改成任意对象;
如果两个线程针对同一个对象进行加锁,就会出现锁竞争/锁冲突,一个线程能够获取到锁(先到先得)另一个线程阻塞等待,等待到上一个线程解锁,它才能获取锁成功,否则就不会;
如果两个线程针对不同对象加锁,此时不会发生锁竞争/锁冲突,这俩线程都能获取到各自的锁,不会有阻塞等待了;
两个线程,一个线程加锁,一个线程不加锁这个时候就不会有锁竞争。
线程不安全的集合类 | 线程安全的集合类 |
ArrayList |
Vector (
不推荐使用
)
|
LinkedList |
HashTable (
不推荐使用
)
|
HashMap |
ConcurrentHashMap
|
TreeMap | StringBuffer |
HashSet | |
TreeSet | |
StringBuilder |
虽然有的集合类是加锁了,但是在使用时并不是建议无脑使用加锁的集合类,因为加锁也需要很多的时间开销(根据情况进行选择)
还有的虽然没有加锁,但是不涉及 "修改", 仍然是线程安全的:String