Java提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized ,而另一个是 JDK 实现的 ReentrantLock.
一、synchronized
一段synchronized的代码被一个线程执行之前,他要先拿到执行这段代码的权限,在 java里边就是拿到某个同步对象的锁(一个对象只有一把锁); 如果这个时候同步对象的锁被其他线程拿走了,他(这个线程)就只能等了(线程阻塞在锁池 等待队列中)。 取到锁后,他就开始执行同步代码(被synchronized修饰的代码);线程执行完同步代码后马上就把锁还给同步对象,其他在锁池中 等待的某个线程就可以拿到锁执行同步代码了。这样就保证了同步代码在统一时刻只有一个线程在执行。
1. 同步一个代码块
public void func() {
synchronized (this) {
// ...
}
}
它只作用于同一个对象,如果调用两个对象上的同步代码块,就不会同步。
2. 同步一个方法
public synchronized void func() {
// ...
}
与同步一个代码块一样,作用于同一个对象。
3. 同步一个类
public class SynchExam {
public void func2() {
synchronized (SynchExam.class) {
for (int i = 0; i < 10; i++) {
System.out.println(i + " ");
}
}
}
}
作用于整个类,也就是说两个线程调用同一个类的不同对象的这种语句时,也会进行同步。
4.同步一个静态方法
public synchronized static void fun() {
// ...
}
也是作用于整个类。
二、一个例子
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
// 多线程对同一个值进行自增运算,查看结果
public class Thread_Crease_T1 {
private volatile int count = 0;
public void increase(String str) {
synchronized (str) {
count++;
}
}
public int getCount() {
return count;
}
public Thread_Crease_T1(){ }
public static void main(String[] args) throws InterruptedException {
ExecutorService service = Executors.newCachedThreadPool();
Thread_Crease_T1 thread_crease_t1 = new Thread_Crease_T1();
for(int i = 0; i < 500; i++) {
String str = new String(String.valueOf(i));
service.execute(() -> {
thread_crease_t1.increase(str);
});
}
System.out.println(thread_crease_t1.getCount());
service.shutdown();
}
}
上述代码的作用是,通过线程池来实现调用多个线程执行对象 thread_crease_t1 的 increase() 操作。
分析问题之前,我们首先要知道,如果要对这些线程进行同步,那么这些线程所持有的对象锁应当是共享且唯一的!
上述代码的执行结果每次都小于500.
查看代码的 increase() 操作,可以看到,synchronized 里面是 str 对象。
而我们的 str 则是在每次线程执行之前 new 出来的一个新的对象。所以这些线程所执行的对象锁并不是唯一的!!
前面我们说到,synchronized( SynchExam.class) 作用于整个类。 我们知道,class对象是在类加载的加载阶段,在内存中生成的一个代表该类的 Class 对象,在一个类中,该Class对象是唯一的。所以,我们使用 synchronized( SynchExam.class) 的时候,实际上也就是锁住了那个类的Class对象。
在前面得了例子中,我们修改为如下代码:
public class Thread_Crease_T1 {
private volatile int count = 0;
private String lock;
public void increase() {
synchronized (lock) {
count++;
}
}
public int getCount() {
return count;
}
public Thread_Crease_T1(String lock){
this.lock = lock;
}
public static void main(String[] args) throws InterruptedException {
ExecutorService service = Executors.newCachedThreadPool();
String lock = new String("lock");
Thread_Crease_T1 thread_crease_t1 = new Thread_Crease_T1(lock);
for(int i = 0; i < 500; i++) {
service.execute(() -> {
thread_crease_t1.increase();
});
}
System.out.println(thread_crease_t1.getCount());
service.shutdown();
}
}
代码中可以看到,我们将 synchronized 里面的 str 修改为 lock, 而 lock 是main中创建的一个String类型的对象。并通过构造函数传递给 thread_crease_T1 对象。根据Java方法的传值特点,我们知道,这些线程的lock变量实际上指向的是堆内存中的 同一个区域,即存放main函数中的lock变量的区域。也就是他们指向的是同一个String类型的对象,对象锁是共享且唯一的!
小结:
1、对于同步的方法或者代码块来说,必须获得对象锁才能够进入同步方法或者代码块进行操作;
2、如果采用method级别的同步,则对象锁即为method所在的对象,如果是静态方法,对象锁即指method所在的
Class对象(唯一);
3、对于代码块,对象锁即指synchronized(abc)中的abc;
4、因为第一种情况,对象锁即为不同的 String 对象。有多个,所以同步失效。第二种共用同一个对象锁lock,因此同步生效。
其他正确的方法:
- 使用 synchronized( Thread_Crease_T1.class) 进行同步。
- 使用 public synchronized void increase() {} 对该方法进行同步。
- 使用原子操作类来实现自增操作。
所谓原子操作类,指的是java.util.concurrent.atomic包下,一系列以Atomic开头的包装类。例如AtomicBoolean,AtomicInteger,AtomicLong。它们分别用于Boolean,Integer,Long类型的原子性操作。
使用 AtomicInteger 来重写上述操作。
public class Thread_Crease_T3 {
private volatile AtomicInteger count = new AtomicInteger(0);
public void increase() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
Thread_Crease_T3 thread_crease_t3 = new Thread_Crease_T3();
for(int i = 0; i < 500; i++) {
service.execute(() -> {
thread_crease_t3.increase();
});
}
service.shutdown();
System.out.println(thread_crease_t3.getCount());
}
}
三、CAS机制(Compare And Swap)
Atomic原子类是如何保证线程安全的?让我们首先来看一下AtomicLong的自增方法incrementAndGet():
public final long incrementAndGet() {
// 无限循环,即自旋
for (;;) {
// 获取主内存中的最新值
long current = get();
long next = current + 1;
// 通过CAS原子更新,若能成功则返回,否则继续自旋
if (compareAndSet(current, next))
return next;
}
}
private volatile long value;
public final long get() {
return value;
}
可以发现其内部保持着一个volatile修饰的long变量,volatile保证了long的值更新后,其他线程能立即获得最新的值。
在incrementAndGet中首先是一个无限循环(自旋),然后获取long的最新值,将long加1,然后通过compareAndSet()方法尝试将long的值有current更新为next。如果能更新成功,则说明当前还没有其他线程更新该值,则返回next,如果更新失败,则说明有其他线程提前更新了该值,则当前线程继续自旋尝试更新。
CAS的基本思想是认为当前环境中的并发并没有那么高,比较乐观的看待整个并发。 先进行操作,如果没有其他线程争用共享数据,那么操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观地并发策略地许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。
更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
旧的预期值与内存地址 V 中的实际值相同,也就是这个过程中没有其他线程修改该值。
四、关于自旋锁
互斥同步进入阻塞状态的开销都很大,应该尽量避免。许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想则是让一个线程在一个请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果这段时间内能够获得锁,就可避免进入阻塞状态。
优点:自旋锁适合于共享数据的锁定状态很短的场景。
缺点:
- 需要进行忙循环操作占用 CPU 时间,不适合锁定状态很长的场景。
- 递归死锁:试图递归地获得自旋锁必然会引起死锁:递归程序的持有实例在第二个实例循环,以试图获得相同自旋锁时,不会释放此自旋锁。
五、CAS的缺点
1.CPU开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
2.不能保证代码块的原子性
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。 多个原子操作合并后的操作不是原子的,也即:原子+原子!=原子。
3.ABA问题
这是CAS机制最大的问题所在。当要更改的值从A变为B,之后又变为A,则检查时可能会发现没有发生变化,实际上已经发生了变化。 (解决办法是在改变值的同时加上版本号)