Java并发 - 理论&线程基础
Java并发 - 理论&线程基础
在并发环境下,经常会发生线程不安全等问题。
这些问题出现的根本原因要归结于并发的三要素没有全部得到满足:可见性、原子性、有序性。
可见性:CPU缓存引起
可见性指的是一个线程对共享变量的修改,另外一个线程立刻就能看到。例如:
1 | // Thread A |
假设执行线程A的是CPU1,执行线程B的是CPU2。当线程A执行i = 10
时,会先把i的初始值加载到CPU1的缓存中,然后赋值为10;此时CPU1的缓存中i的值就为10,但没有立即写入到主存中。
此时线程B执行j = i
,会先去主存读取i的值然后加载到CPU2的缓存中,由于主存中i的值仍为0,那么j的值也会变成0,而不是10。
线程B没有能够立即看到线程A对于变量i的修改。
原子性:时分复用引起
原子性指的是多个操作要么全部执行成功要么全都不执行。例如:
1 | int i = 1; |
这里面i += 1
这个语句需要执行三条CPU指令:
- 首先将变量i从主存读取到CPU寄存器
- 然后在CPU寄存器中执行i+1操作
- 最后将结果写入主存/缓存
由于CPU时分复用的机制,线程A执行了第一条指令后,CPU就切换到线程B,假设线程B执行了这三条指令之后,CPU才切换回线程A执行后续的两条指令,那么写到主存中的i值就是2而不是3了。
有序性:重排序引起
有序性指的是程序执行的顺序以代码的先后顺序为依据。例如:
1 | int i = 0; |
从代码顺序上看,语句1在语句2之前。但JVM在真正执行这段代码时可能会发生指令重排序。
为了提高性能,在执行程序时编译器和处理器经常会对指令做重排序,分三种类型:
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重排语句的执行顺序
- 指令级并行的重排序:若不存在数据依赖性,处理器可以改变语句对应的机器指令的执行顺序
- 内存系统的重排序:由于处理器使用缓存和读写缓冲区,导致加载和存储操作像是乱序执行
关于重排序的详解,可以看这篇文章Java内存模型中的相关内容。
Java如何解决并发问题——JMM
Java内存模型这篇文章详细写了JMM的具体内容。
JMM的本质实际上是规范了JVM如何提供按需禁用缓存和编译优化的方法,包括:
- volatile/synchronized/final关键字
- Happens-Before原则
相应地,针对前文所述的并发三元素,JMM是这样做到保障的:
可见性
JMM通过Happens-Before原则来保证可见性。使用volatile关键字也可确保可见性。当volatile修饰的共享变量被修改时,其新值会立即更新到主存。另外,synchronized和Lock也能够确保可见性。
原子性
JMM规定只有简单的读取、赋值(数字赋给变量,变量之间的赋值不算)才是原子操作。若要实现更大粒度的原子性,可通过synchronized和Lock来实现。
有序性
JMM通过Happens-Before原则来保证有序性。另外,使用volatile/synchronized/Lock都可以确保有序性。
关于volatile/synchronized关键字的详细内容,可以看这篇文章Java关键字。
Happens-Before原则
- 程序次序规则:在一个线程内,按照控制流顺序,若操作A先行发生于操作B,那么操作A所产生的影响对于操作B是可见的。
- 管程锁定规则:对于同一个锁,若一个unlock操作先行发生于lock操作,则该unlock所产生的影响对于lock是可见的。
- volatile变量规则:对于同一个volatile变量,若对于该变量的写操作先行发生于读操作,则写操作产生的影响对于读操作是可见的。
- 线程启动规则:对于同一个Thread,调用该Thread的start方法所产生的影响对于该Thread的每一个动作都是可见的。
- 线程终止规则:对于同一个Thread,其所有操作产生的影响对于调用该Thread的join方法和isAlive方法都是可见的。
- 线程中断规则:对于同一个Thread,调用该Thread的interrupt方法产生的影响对于该Thread检测到中断事件是可见的。
- 对象终结规则:对于同一个Thread,其构造方法结束所产生的影响对于其finalize方法的开始执行是可见的。
- 传递性规则:若操作A先行发生于操作B,操作B先行发生于操作C,则操作A所产生的影响对于操作C是可见的。
线程使用方式
实现Runnable接口
实现自定义的run
方法。通过Thread调用start方法来启动线程。
1 | public static void main(String[] args) { |
实现Callable接口
相较于Runnable方法,多了返回值,返回值可以通过FutureTask
封装。
1 | public static void main(String[] args) throws ExecutionException, InterruptedException { |
继承Thread类
当调用start方法启动一个线程时,虚拟机会将该线程放入就绪队列中,直到其被调度才会执行run方法。
1 | public static void main(String[] args) { |
实现接口会更好一些,因为:
- Java不支持多重继承,因此继承了Thread类就无法继承其它类,但是Java可以实现多个接口;
- 类可能只要求可执行就行,继承整个Thread类开销过大。
基础线程机制
Executor
Executor管理多个异步任务的执行,无需用户显式地管理线程的生命周期。主要有三种Executor:
- CachedThreadPool:一个任务创建一个线程
- FixedThreadPool:所有任务只能使用固定大小的线程
- SingleThreadExecutor:相当于大小为1的FixedThreadPool
1 | public static void main(String[] args) { |
Daemon
守护线程是程序运行时在后台提供服务的线程,并不一定需要。
当所有非守护线程结束时,程序就会终止,所有的守护线程也就被杀死。main()
不属于守护线程。
1 | Thread thread = new Thread(new MyThread()); |
sleep()
休眠当前线程若干毫秒。
yield()
调用yield()
方法表示当前线程愿意放弃CPU,让给其他线程来使用。但并不一定保证会让出资源。
线程协作
join()
在线程中调用另一个线程的join()
方法,会将当前线程挂起,直到目标线程结束。
wait()
/notify()
/notifyAll()
调用wait()
使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用notify()
或者notifyAll()
来唤醒挂起的线程。
使用wait()
挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行notify()
或者notifyAll()
来唤醒挂起的线程,造成死锁。
wait()
和sleep()
的区别:前者是Object类的方法,后者是Thread类的方法;前者会释放锁,后者不会。
await()
/signal()
/signalAll()
调用await()
方法使线程等待,其它线程调用signal()
或signalAll()
方法唤醒等待的线程。相比于wait()
这种等待方式,await()
可以指定等待的条件,因此更加灵活。
线程安全的实现方法
线程安全指的是多个线程同时访问共享资源时,不会出现不准确的结果或者不稳定的行为。
具体的实现方法可分为三种:
互斥同步/阻塞同步
依靠关键字synchronized和ReentrantLock。具体内容可以看这两篇文章:Java关键字、JUC锁。
非阻塞同步
由于互斥同步是一种悲观的并发策略,无论是否存在数据竞争,都会加锁。实际上并不是所有操作都需要加锁的,这导致了额外的线程阻塞,产生了性能上的缺陷。
可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程竞争共享数据,那操作就成功了,否则采取补偿措施(不断重试,直到操作成功),即非阻塞同步。
主要是通过CAS的思想来实现,具体内容可以看这篇文章:Java原子类。
无同步
要保证线程安全,并不一定就要实现同步。如果一个方法本来就不涉及共享数据,则无需做任何同步。
依靠栈封闭和线程本地存储可以实现。
栈封闭:多个线程访问同一个方法的局部变量时不会有线程安全问题,因为局部变量存储于虚拟机栈中,是线程私有的。具体内容可以看这篇文章:JUC线程池。
线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
线程本地存储主要通过ThreadLocal来实现,关于ThreadLocal的具体内容可以看这篇文章:ThreadLocal。