高效并发对于现在的程序来说是一件非常有意义的事情,程序能够高效并发可以最大限度利用计算机的运算能力,使程序可以面对更加复杂的运行环境。
高效并发并不是盲目的在追求高并发,它有很重要的前提就是要保证程序可以正确无误的运行。在现在越来越复杂的程序系统来说保证程序可以正确运行需要解决第一个问题就是线程安全的问题。
首先我们来了解下什么是线程安全呢?
在《Java Concurrency In Practice》的作者对线程安全的定义是:当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任务其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。
在JAVA语言中,我们可以把各种共享数据分为一下几类:不可变、绝对线程安全、相对线程安全、线程兼容、线程独立。
不可变:如基本数据类型修饰成final后且初始化后就是不可变的。
线程安全的实现方法
1、互斥同步-synchronized(阻塞同步)
通过在同步块前后添加monitor enter和monitor exit字节码指令,如果执行monitor enter指令成功则会将锁的计数器加1,如果执行失败则会阻塞当前线程,如果执行monitor exit指令成功,则将锁计数器减1,当计数器为0时,对象锁就被释放了。
线程同步机制就是典型的“以时间换空间”,采用排队稍等的方法,一个个等待,直到前面一个用完,后面的才跟上,多人共用一个变量,用synchronized锁定排队。****
对同一个线程来说,synchronized同步块对同一条线程来说是可重入的,不会出现把自己锁死的问题。怎么理解?通过下面的模拟,同一个线程获取同一个对象的锁来解释!
public static void main(String[] args) throws InterruptedException {
SynchronizedTest synchronizedTest = new SynchronizedTest();
//调用同步代码块方法
synchronizedTest.synchronizedTest(0);
}
public void synchronizedTest(int b) throws InterruptedException {
if (b == 2) return;
synchronized (this) {
String threadName = Thread.currentThread().getName();
log.info("线程{},{}获得锁", threadName, b);
//递归调用,用于模拟同一个线程获取同一个对象的锁
synchronizedTest(++b);
log.info("线程;{},{}释放锁", threadName, b);
}
}
上面例子将会输出如下:
2019-03-09 15:20:38,880 INFO - 线程main,0获得锁
2019-03-09 15:20:38,882 INFO - 线程main,1获得锁
2019-03-09 15:20:38,882 INFO - 线程;main,2释放锁
2019-03-09 15:20:38,882 INFO - 线程;main,1释放锁
通过上面实例,发现同一个线程main获取到了synchronizedTest 对象锁之后还是可以继续获取该对象的锁,从这里就可以证明synchronized获得的锁是可以重入的。
而对于不同的线程,如果已经有一个线程进入了同步代码块,则其他线程一定会阻塞并等待前面进入同步块的线程执行完。
2、互斥同步-JUC
ReentrantLock有主要三个高级功能:
A.等待可中断:正在等待前一个线程释放锁的线程可以选择放弃等待,改为处理其他事情。
B.可实现公平锁:指多个线程等待同一个锁时候,必须按照申请锁的时间顺序来依次获得锁,synchronized是非公平锁,而ReentrantLock默认是非公平锁,可以通过构造函数创建公平锁
C.ReentrantLock锁可以绑定多个条件
以上Synchronized和ReentrantLock实现的锁都是阻塞形式的锁,属于一种悲观的并发策略。
3、非阻塞同步
这是一种基于冲突检测的乐观并发策略,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了,如果共享数据有争用,产生了冲突,那就采取其他补救措施(最常见的补偿措施就是不断重试,直到成功为止)。这种乐观并发策略大部分虚拟机实现不会使线程挂起,所以不会导致线程阻塞。现在硬件指令集的实现可以将操作和冲突检测两个步骤具备原子性,现在这种指令集常用的有:
a、测试并设置
b、获取并增加
c、交换
d、比较并交换(compare-and-swap,即CAS)
e、加载链接/条件存储
CAS:cas需要三个操作数,一个是内存中的原值o,一个是旧的预期值w,一个新值n.cas指令执行时候,当且仅当o符合w时,处理器才将n更新o的值,否则不进行更新,但是不管是否更新了内存的旧值o,都返回内存中的旧址o,这些操作处理起来就是原子性的。
JDK1.5之后才能使用cas操作,该操作由sun.misc.Unsafe类的一些本地方法提供。
下面举个例子说明CAS的实现与作用:
private static volatile int a = 0;
private static volatile AtomicInteger b = new AtomicInteger(0);
public static void increaseA() {
a++;
}
public static void increaseB() {
b.incrementAndGet();
}
public static void main(String\[\] args) throws InterruptedException {
int\[\] threads = new int\[20\];
for (int thread : threads) {
new Thread(() -> {
for (int i = 0; i < 10000; i++) {
increaseA();
increaseB();
}
}).start();
}
Thread.sleep(2000);
log.info("a:{}", a);
log.info("b:{}", b);
}
上例子输入如下:
2019-03-09 17:07:21,721 INFO - a:184335
2019-03-09 17:07:21,722 INFO - b:200000
上面代码定义了一个volatile整形变量,另一个volatile修饰AtomicInteger对象,默认值均为0,创建20个线程,每个线程对a,b进行10000自增操作如果代码正确并发,最后的结果应该是a=200000,b=20000,但是实际并不会这样,a总是小于200000,而b每次都是等于200000,我们知道volatile可以保证线程可见性,但是并不保证volatile修饰的变量的操作是原子性的。出现这种现象的原因就是在于a++操作并不是原子性的,而b.incrementAndGet()是原子性的
4.无同步方案
可重入代码,这类代码天然就是线程安全的。
线程本地存储,最常用的ThreadLocal:当某个频繁执行的操作需要一个临时对象,例如一个缓存区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。
“ThreadLocal”就是典型的“以空间换时间”,它可以为每一个线程提供一份变量,因此可以同时访问并互不干扰。