目录
一、线程安全
1、什么是线程安全
虽然多线程编程极大地提高了效率,但是也会带来一定线程安全的隐患。
举个例子:现在有两个线程分别从网络上爬取数据,然后插入一张数据库表中,要求不能插入重复的数据。
在插入数据的过程中存在两个操作:1)先检查数据库中是否存在该条数据;2)如果存在,则不插入;如果不存在,则插入到数据库中。
假如两个线程分别用thread-1和thread-2表示,某一时刻,thread-1和thread-2都读取到了数据X,那么可能会发生这种情况:因为thread-1和thread-2同时开启,thread-1去检查数据库中是否存在数据X和thread-2也去检查数据库中是否存在数据X是同时发生的。结果两个线程检查的结果都是数据库中不存在数据X,那么两个线程都分别将数据X插入数据库表当中。
所以在单线程中就不会出现线程安全问题,thread-1先去数据库中检查没有数据X,等到thread-1插入完数据后thread-2才能开启。而在多线程编程中,有可能会出现同时访问同一个资源的情况,这种资源可以是各种类型的的资源:一个变量、一个对象、一个文件、一个数据库表等,而当多个线程同时访问同一个资源的时候,就会存在一个问题:由于每个线程执行的过程是不可控的,所以很可能导致最终的结果与实际上的愿望相违背或者直接导致程序出错。
Java Concurrency in Practice中是这么描述线程安全的:
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其它的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。
也就是说:
- 线程安全是和对象密切绑定的;
- 线程的安全性是由于线程调度和交替执行造成的;
- 线程安全的目的是实现正确的结果
我们还可以通过jvm内存管理的角度去理解线程安全问题:
数据的存储相对于cpu运算能力需要消耗大量时间,为了充分利用运算能力引入了缓存。cpu计算时数据读取顺序优先级:寄存器->高速缓存->内存,计算过程中,有些数据可能被频繁读取,这些数据被存储在寄存器和高速缓存中,当线程计算完后,这些缓存的数据在适当的时候应该写回内存。这个适当的时机就能确保缓存的一致性。
Java内存模型规定了所有的变量都存储在主内存(Runtime Data Area 的 Heap)中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须是工作内存中进行,也就是操作的是主内存副本拷贝而不能直接读写主内存中的变量。线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。当多个线程同时读写某个内存数据时,各个线程都从主内存中获取数据,线程之间数据是不可见的,就可能会产生寄存器/高速缓存/内存之间的同步问题。
2、如何避免线程安全问题
从JVM层面避免线程安全问题主要围绕着并发过程中的原子性、可见性、有序性这三个特征(有点类似数据库中事务的ATOM的四个特性)。
2.1、原子性
原子性就是操作不能被线程调度机制中断,要么全部执行完毕,要么不执行。
比如:k=i++; 就不具有原子性,如果把这行代码看成线程的话,那么其中涉及到两个操作,先将i的值赋给k,然后i进行自增1。另外在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性,这就导致了long、double类型的变量在32位虚拟机中是非原子操作。
可以使用synchronized和Lock保证原子性。它们k能够保证任一时刻只有一个线程执行该代码。
2.2、可见性
可见性就是一个线程对共享变量做了修改之后,其他的线程立即能够看到修改后的值。Java内存模型将线程的工作内存中的变量修改后的值同步到主内存,并且在读取变量前需要从主内存刷新最新值到工作内存中。
可以通过使用volatile关键字来保证可见性(不能保证原子性)。
非volatile变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中,非volatile变量被修改之后,由于被写入主存的时间是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值。
volatile变量进行读写的时候,会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会跳过CPU cache这一步去内存中读取新值。
2.3 有序性
有序性就是程序执行的顺序按照代码的先后顺序执行。为了提高性能,编译器和处理器常常会对指令做重排序,CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。可见重排序过程会影响到多线程并发执行的正确性。
可以通过volatile关键字,synchronized和Lock来保证有序性,volatile关键字本身就包含了禁止指令重排序的语义;synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
综上,考虑到原子性、可见性和有序性,基本上所有的并发模式都可以通过“序列化访问临界资源”来解决,即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。说白了就是"排队一个一个来"。
通常来说,是在访问临界资源的代码前面加上一个锁,当访问完临界资源后释放锁,让其他线程继续访问。
本文主要聊的是synchronized关键字~
二、Synchronized关键字
1、synchronized,上锁
在Java中,每一个对象都拥有一个锁标记(monitor),也称为监视器,多线程同时访问某个对象时,线程只有获取了该对象的锁才能访问。
而synchronized关键字能为方法或代码块上"锁",这把锁就是互斥锁,即能到达到互斥访问目的的锁。当某个线程调用该对象的synchronized方法或者访问synchronized代码块时,这个线程便获得了该对象的锁,其他线程暂时无法访问这个方法,只有等待这个方法执行完毕或者代码块执行完毕,这个线程才会释放该对象的锁,其他线程才能执行这个方法或者代码块。
2、怎么上锁
synchronized能够把任何一个非null对象当成锁,实现由两种方式:
- 类锁,当synchronized作用于静态方法时是给class加锁
- 对象锁,当synchronized作用于一个对象实例时或非静态方法时
细分的话可以是三种层次:
1、单个对象的同步。
每个方法可以同步到不同的对象,对象之间是相互独立的。
private Object synObject1 = new Object();
private Object synObject2 = new Object();
}
public void f1() {
synchronized (synObject1) {
//TODO
}
}
public void f2() {
synchronized (synObject2) {
//TODO
}
}
f1与f2分别同步到不同的对象上,只要获得相应同步对象的对象锁,线程就可以运行。
2、同步到当前类实例对象上。
当某一个方法同步到当前的类实例对象上时,线程只有获得当前类实例的对象锁才可以继续运行。同步到当前类实例对象上有两种方法:
1)同步对象设为this
public void f3() {
synchronized (this) {
}
}
2)在方法上使用synchronize关键字
public synchronized void f4() {
}
synchronized代码块使用起来会比synchronized方法要灵活得多。因为也许一个方法中只有一部分代码只需要同步,如果此时对整个方法用synchronized进行同步,会影响程序执行效率。而使用synchronized代码块就可以实现只对需要同步的地方进行同步。
3、同步到当前类实例上。
当使用一个静态对象作为同步对象时,线程只有获得当前类实例时,才可以继续运行,也就是所谓的类锁。也可以直接获取当前类实例来作为同步对象。
public class Test {
public void f6() {
synchronized (Test.class) { //synchronized (Class.forName("com.Test")) {
}
}
}
我们可以通过反编译来看一下synchronized关键字到底做了什么事情
package test;
public class SynchronizedTest {
private Object object = new Object();
public void mothod1(Thread thread){
synchronized (object) {
}
}
public synchronized void method2(Thread thread){
}
public void method3(Thread thread){
}
}
反编译后:
可见,synchronized代码块实际上多了monitorenter和monitorexit两条指令。monitorenter指令执行时会让对象的锁计数加1,而monitorexit指令执行时会让对象的锁计数减1,其实这个与操作系统里面的PV操作很像,操作系统里面的PV操作就是用来控制多个线程对临界资源的访问。对于synchronized方法,执行中的线程识别该方法的 method_info 结构是否有 ACC_SYNCHRONIZED 标记设置,然后它自动获取对象的锁,调用方法,最后释放锁。如果有异常发生,线程自动释放锁。
3、Demo
我们来模拟一下多线程调用insertData对象插入数据的过程:
未上锁:
import java.util.ArrayList;
public class SynchronizedTest {
public static void main(String[] args) {
final InsertData insertData = new InsertData();
new Thread() {
public void run() {
insertData.insert(Thread.currentThread());
};
}.start();
new Thread() {
public void run() {
insertData.insert(Thread.currentThread());
};
}.start();
}
}
class InsertData {
private ArrayList<Integer> arrayList = new ArrayList<Integer>();
public void insert(Thread thread){
for(int i=0;i<5;i++){
System.out.println(thread.getName()+"在插入数据"+i);
arrayList.add(i);
}
}
}
运行结果
说明两个线程在同时执行insert方法,会引发线程安全隐患
而如果在insert方法前面加上关键字synchronized的话,运行结果为:
从上输出结果说明,Thread-1插入数据是等Thread-0插入完数据之后才进行的。说明Thread-0和Thread-1是顺序执行insert方法的。
也可以使用synchronized代码块,上述代码可以修改为:
class InsertData {
private ArrayList<Integer> arrayList = new ArrayList<Integer>();
public void insert(Thread thread){
synchronized (this) {
for(int i=0;i<100;i++){
System.out.println(thread.getName()+"在插入数据"+i);
arrayList.add(i);
}
}
}
}
OR
class InsertData {
private ArrayList<Integer> arrayList = new ArrayList<Integer>();
private Object object = new Object();
public void insert(Thread thread){
synchronized (object) {
for(int i=0;i<100;i++){
System.out.println(thread.getName()+"在插入数据"+i);
arrayList.add(i);
}
}
}
}
另外,如果一个线程执行一个对象的非static synchronized方法,另外一个线程需要执行这个对象所属类的static synchronized方法,此时不会发生互斥现象,因为访问static synchronized方法占用的是类锁,而访问非static synchronized方法占用的是对象锁,所以不存在互斥现象。
public class SynchronizedTest {
public static void main(String[] args) {
final InsertData insertData = new InsertData();
new Thread(){
@Override
public void run() {
insertData.insert();
}
}.start();
new Thread(){
@Override
public void run() {
insertData.insert1();
}
}.start();
}
}
class InsertData {
public synchronized void insert(){
System.out.println("执行insert");
try {
Thread.sleep(5000); //insert执行完需要5秒钟
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("insert完毕");
}
public synchronized static void insert1() {
System.out.println("执行静态的insert1");
System.out.println("静态的insert1完毕");
}
}
运行结果:
可见第一个线程里面执行的是insert方法,不会导致第二个线程执行insert1方法发生阻塞现象。
参考资料:
1、https://blog.csdn.net/alex_xfboy/article/details/22810249