目录
- 一、线程中的基本状态
- 二、线程安全问题
- 三、线程安全的标准类
- 四、synchronized 关键字-监视器锁monitor lock
- synchronized 的特性
- 五、volatile 关键字
一、线程中的基本状态
NEW
: 安排了工作, 还未开始行动, 就是创建了Thread对象, 但还没有执行start方法(内核里面还没有创建对应PCB), 这个状态是java内部的状态, 与操作系统中线程的状态没有关联.
RUNNABLE
: 可工作的, 又可以分成正在工作中和即将开始工作(即正在CPU上执行的任务或者在就绪队列里随时可以去CPU上执行的).
BLOCKED
: 线程正在等待锁释放而引起的阻塞状态(synchronized加锁).
WAITING
: 线程正在等待等待唤醒而引起的阻塞状态(waitf方法使线程等待唤醒).
TIMED_WAITING
: 在一段时间内处于阻塞状态, 通常是使用sleep或者join(带参数)方法引起.
TERMINATED
:Thread对象还存在, 但是关联的线程已经工作完成了, 这个状态也是java内部的状态, 与操作系统中线程的状态没有关联.
线程的状态其实是一个枚举类型:Thread.State
二、线程安全问题
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象时线程安全的。
举一个存在线程安全问题的例子:
class Counter {
public int count = 0;
public void add() {
count++;
}
}
public class Demo {
public static void main(String[] args) {
Counter counter = new Counter();
// 搞两个线程, 两个线程分别针对 counter 来 调用 5w 次的 add 方法
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) {
e.printStackTrace();
}
// 打印最终的 count 值
System.out.println("count = " + counter.count);
}
}
为什么会出现这种情况呢?
原因还是线程的抢占式执行, 线程调度的顺序是随机的, 就造成线程间自增的指令集交叉, 导致运行时出现两次或者多次自增但值只会自增一次的情况, 导致得到的结果会偏小.
一次的自增操作本质上可以分成三步:
把内存中变量的值读取到CPU的寄存器中(load).
在寄存器中执行自增操作(add)
将寄存器的值保存至内存中(save)
如果是两个线程并发的执行count++, 此时就相当于两组 load, add, save进行执行, 此时不同的线程调度顺序就可能会产生一些结果上的差异.
线程加锁
为了解决由于 “抢占式执行” 所导致的线程安全问题, 我们可以针对当前所操作的对象进行加锁, 当一个线程拿到该对象的锁后, 就会将该对象锁起来, 其他线程如果需要执行该对象所限制任务时, 需要等待该线程执行完该对象这里的任务后才可以.
在Java中最常用的加锁操作就是使用synchronized关键字进行加锁.
方式一:使用synchronized关键字修饰普通方法, 这样会给方法所对在的对象加上一把锁.
class Counter {
public int count = 0;
synchronized public void add() {
count++;
}
}
方法二:使用synchronized关键字对代码段进行加锁, 需要显式指定加锁的对象.
class Counter {
public int count = 0;
public void add() {
synchronized (this) {
count++;
}
}
}
方法三:使用synchronized关键字修饰静态方法, 相当于对当前类的类对象进行加锁.
class Counter {
public static int count = 0;
synchronized public static void add() {
count++;
}
}
加锁本质上就是把并发变成了串行执行, 这样的话这里的自增操作其实和单线程是差不多的, 甚至上由于add方法, 要做的事情多了加锁和解锁的开销, 多线程完成自增可能比单线程的开销还要大, 那么多线程是不是就没用了呢? 其实不然, 对方法加锁后, 线程运行该方法才会加锁, 执行完该方法的操作后就会解锁, 此方法外的代码并没有受到限制, 这部分程序还是可以多线程并发执行的, 这样整体上多线程的执行效率还是要比单线程要高许多的.
产生线程安全问题的原因
1.线程是抢占式执行的,线程间的调度充满随机性。(线程不安全的根本原因)
2.多个线程对同一个变量进行修改操作。
3.原子性.
4.指令重排序和内存可见性问题
三、线程安全的标准类
Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
但是还有一些是线程安全的. 使用了一些锁机制来控制.
- Vector (不推荐使用)
- HashTable (不推荐使用)
- ConcurrentHashMap
- StringBuffer
还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的
- String
四、synchronized 关键字-监视器锁monitor lock
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 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.
如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取
到锁, 并让计数器自增.
解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)五、volatile 关键字
volatile 能保证内存可见性
代码在写入 volatile 修饰的变量的时候,改变线程工作内存中volatile变量副本的值
将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候,从主内存中读取volatile变量的最新值到线程的工作内存中
从工作内存中读取volatile变量的副本volatile 不保证原子性
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见
性.