1、通过程序认识什么是原子性,举个例子如下,启动100线程,每个线程执行10000次n++操作,理论上等待100个线程都执行完以后,主线程会输出100 0000,接下来我们来看看执行结果。
package com.yang.Threads;
import java.util.concurrent.CountDownLatch;
/**
* @Author: Gy
* @Description: synchronized悲观锁 保证原子性
* @Date
* @Modified By:
*/
public class TestAtomicity {
public static int num = 0;
public static Thread[] ts = new Thread[100];
public static CountDownLatch count = new CountDownLatch(ts.length);
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < ts.length; i++) {
ts[i] = new Thread(()->{
for (int j = 0; j < 10000; j++) {
//--A -- synchronized (TestAtomicity.class){
num ++;
//--B--- }
}
count.countDown();
});
ts[i].start();
}
count.await();
System.out.println(num);
}
}
执行结果为:
看了上面的执行结果,是不是有点出乎意料了,我们预期的结果100 0000呢?(原因是多线程共享变量n时,n++没有保证原子性操作)
2、race condition 竞争条件
指的是多个线程访问共享变量的时候产生竞争,这种环境下容易产生预期之外的结果,也就是数据的不一致。上边的小程序100个线程共同访问共享变量n,每个线程先要从内存中把n读出来,然后执行自增,最后把自增后的值写回内存,如果线程t1在回写到内存之前,线程t2读出n的值,那么t1和t2执行完,相当于只做了一次自增操作,或者说t1和t2做了同样的操作,所以说结果小于预期的100 0000是因为线程之间做了好多相同的操作。如果能够保证每个线程的每一次n++操作都是一个完整的过程,中间不被其他线程捣乱(t1回写完t2再去读),保证线程之间有序执行(线程同步),这个问题就迎刃而解了
3、那么如何保证原子性操作呢呢?答案是上锁,把上面程序中注释的A、B分别释放开,就能够保证预期结果,synchronized代码块中的整体部分,就是同步操作,中间不可被打断。
4、上锁的本质是把并发执行序列化,看小程序
package com.yang.Threads;
import java.util.concurrent.TimeUnit;
/**
* @Author: Gy
* @Description: 三个线程并发执行,大约2秒同时执行完
* @Date
* @Modified By:
*/
public class TestSync {
private static Object obj = new Object();
public static void main(String[] args) {
Runnable runnable = () -> {
System.out.println(Thread.currentThread().getName()+ "start...");
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"end");
};
for (int i = 0; i < 3; i++) {
new Thread(runnable).start();
}
}
}
如果加上synchronized,三个原本并发执行的线程变成了序列化执行(同步),每个线程的执行都变成了原子性操作,可以把代码中sleep()方法想象成我们具体的业务代码,经过大约6秒三个线程分别执行完,每个线程执行各自的业务是不会被其他线程干扰的。代码及结果如下:
package com.yang.Threads;
import java.util.concurrent.TimeUnit;
/**
* @Author: Gy
* @Description: 三个线程同步(序列化)执行,大约共6秒分别执行完
* @Date
* @Modified By:
*/
public class TestSync {
private static Object obj = new Object();
public static void main(String[] args) {
Runnable runnable = () -> {
synchronized (obj){
System.out.println(Thread.currentThread().getName()+ "start...");
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"end");
}
};
for (int i = 0; i < 3; i++) {
new Thread(runnable).start();
}
}
}
通过不加锁和加锁的效果看,加锁后执行时间是延长的,原本的并发操作转为序列化操作后,执行效率势必会有所降低
5、锁的粒度
- monitor(管程),在操作系统层面,把锁对象叫做monitor,也就是小obj对象
- critical section (临界区),synchronized大括号包含的代码区域,见下图
- 如果临界区代码语句比较多,执行时间长,那么就说明锁的粒度是比较粗的;反之,锁的粒度比较细。所以说我们给业务代码上锁的时候,要看哪些操作需要上锁,哪些没必要上锁,保证锁的粒度尽可能细
6、保证原子性(Atomicity)操作的2种方式
- 悲观锁(synchronized):悲观的认为当前操作会被别的线程打断或干扰,例子看上面使用synchronized的小程序
- 乐观锁/自旋锁/无锁(CAS—Compare And Swap):乐观的认为当前操作不会被别的线程打断或干扰。
下面说说CAS是个啥,假设一个场景,定义一个变量int n = 0;
多线程环境下执行n++;
操作,n++本身不是原子性操作,那么使用CAS是如何使其达到原子性操作的效果呢?看下图
ABA问题:如果共享变量n是一个基本类型如int,那么不用解决这个问题,可以忽略;如果共享变量是一个引用对象,那么就可能对业务造成影响了。解决ABA问题的办法,可以使用添加版本号或者时间戳等方式,具体的实现方式可以去自学一下。
Atomic类使用的就是CAS的机制,看小程序
package com.yang.Threads;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @Author: Gy
* @Description: CAS乐观锁 Atomic类使用的就是CAS机制保证原子性
* @Date
* @Modified By:
*/
public class TestAtomicity2 {
public static AtomicInteger count = new AtomicInteger(0);
static void m(){
for (int i = 0; i < 10000; i++) {
// 这一步保证了原子性 实现compare and swap
count.incrementAndGet();
}
}
public static void main(String[] args) {
List<Thread> threadList = new ArrayList<Thread>();
for (int i = 0; i < 100; i++) {
threadList.add(new Thread(TestAtomicity2::m,"Thread-" + i));
}
threadList.forEach((o)->{
o.start();
try {
//保证每个子线程先执行完
o.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
/* //让主线程睡眠一段时间,保证所有子线程都执行完,主线程再结束
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}*/
System.out.println(count);
}
}
7、乐观锁与悲观锁的使用场景
如果临界区代码执行时间比较长,并且并发的线程多,采用重量级锁;如果临界区执行时间短,并发线程数量少,采用自旋锁。原因是使用重量级锁时,等待的线程是不消耗cpu资源的,而采用自旋锁,等待的线程一直(while)问锁什么时候释放,是消耗cpu资源的。理论上的选择规则是这样的,但是经过对synchronized的不断优化,现在的synchrohnized性能已经得到了很大的提升,添加了一个锁升级的过程,所以实战中我们完全可以直接使用synchronized加锁。具体这个锁升级的过程是如何的,自行了解