为了避免临界区的竞态条件发生,有多种手段可以达到目的。
- 阻塞式的解决方案:synchronized、Lock
- 非阻塞式的解决方案:原子变量,每次对共享资源的读写操作不可再被细分,所以不存在指令执行混乱,这种方法从根本上解决了竞态条件的发生;
使用synchronized解决上述问题,即俗称的【对象锁】,它采用互斥的方式让只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】就会被阻塞住,从而陷入阻塞态,这个【对象锁】实际上就是对共享资源上锁,这样可以使多线程一次只有一个线程能够访问共享资源。这样就能保证拥有锁的线程可以安全地执行临界区的代码,不用担心线程上下文切换,因为即使切换到其它线程,其它线程根本没有权限访问资源(无锁)
本质上讲,是保证临界区对于线程的串行执行。

一、synchronized语法
synchronized(Object obj){ //线程1,线程2(blocked)
临界区
}
括号中的对象即为对象锁,获得该对象锁才能对临界区代码进行访问,线程2会进入阻塞态,直到线程1释放该对象锁,线程2被唤醒,进入就绪状态,可以争夺锁,当拿到对象锁后临界区内代码针对所有线程来说就是串行执行的。
用synchronized解决上述共享问题的代码:
public class TestSynchronized {
static int counter = 0;//临界资源(共享资源)
static Object lock = new Object();//需要被上锁的对象,可以理解成无意义的对象,只是为了实现互斥条件;
public static void main(String[] args) {
Thread t1 =new Thread(()->{
for (int i=0;i<5000;i++){
synchronized (lock){
counter++;
}
}
},"t1");
Thread t2 =new Thread(()->{
for (int i=0;i<5000;i++){
synchronized (lock){//实际可以理解为:只有拿到对该lock对象的锁,才能执行方括号下边的代码
counter--;
}
}
},"t2");
//t1线程和t2线程启动
t1.start();
t2.start();
System.out.println("counter值为:"+counter);
}
}
注意,这里的lock对象实际可以是任何对象,只是把它叫做“对象锁”
synchronized(lock){
counter--;
}
上述对象可以理解为:只有线程拿到对象锁lock后,才可以对临界资源counter进行操作,注意对象锁是被一个线程所拥有。
上述解决问题的代码的执行过程为:
t1线程和t2线程启动时,对象锁lock没有被任何线程持有。这个时候线程t1和线程t2没有特定的执行顺序,因为不一定哪个线程先被CPU调度(再次强调线程是CPU调度的最小单位,线程只有被CPU分配时间片后才能运行),比如是线程t1先被CPU调度并分配时间片,当线程t1执行到synchronized(lock)时,则会持有对象锁lock,并且在时间片内执行循环。当t1线程时间片用完,CPU可能会调度t2线程,这时候当线程t2访问synchronized(lock)时,没有办法获得对象锁lock,因为此时对象锁正在被线程t1持有,所以线程t2会进入阻塞状态(堵在synchronized(lock)这无法继续执行,只有拿到对象锁才能继续向下执行代码),注意线程执行完毕时才会释放对象锁,时间片用完了会进入就绪态,而不是终止态。所以一定是线程t1执行完全部5000次循环后,再由线程t2执行完5000次循环。
故当多个线程访问同一临界资源的时候,要对资源上锁,一个锁对应一种资源。
如果多个线程对应多个锁则没有意义,因为无法完成对某种资源的单独占有。
所以步骤为:
- 创建任意对象
- 每个线程在访问临界资源的时候都要套synchronized(对象锁){//临界区}
二、synchronized理解
你可以做这样的类比:
- synchronized(对象)中的对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人进行计算,线程t1,t2想象成两个人;
- 当线程t1执行到synchronized(room)时就好比t1进入了这个房间,并锁住了门拿走了钥匙,在门内执行count++代码;
- 这时候如果t2也运行到了synchronized(room)时,它发现门被锁住了,只能在门外等待,发生了上下文切换,阻塞住了,只能在这等待锁被释放,无法再执行下去;
- 这中间即使t1的cpu时间片不幸用完,被踢出了门外(不要理解为锁住了对象就能一直执行下去,还是要根据时间片来执行的),这时门还是锁住的,t1仍拿着钥匙,t2线程还在阻塞状态进不来,只有下次轮到t1自己再次获得时间片时才能开门进入;
- 当t1执行完synchronized{}块内的代码,这时候才会从obj房间出来并解开门上的锁,唤醒t2线程把钥匙给他。t2线程这时才可以进入obj房间,锁住了门拿上钥匙,执行它的count—代码;
用图来表示:
如图,线程2尝试获取锁后,拿到锁开始执行临界区代码,写入之前线程上下文切换,线程1开始执行自己的时间片,当执行到synchronized时,因为锁在被线程2持有,所以线程1在此陷入阻塞态,即保证了临界区代码的串行执行,保护了临界区资源。
三、思考
synchronized实际用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
原子性:要么不执行,要么一次执行完毕,在此例子中理解成,某个线程拿到对象锁了,synchronized中的代码就要被该线程执行完毕。
一定区分好:比如线程t1拿到对象锁,虽然时间片用完要cpu切换,但是宏观来讲t1线程内synchronized内的代码是要整体执行完毕的,不会被其它线程执行,只不过因为时间片的切换,可能中间执行有断档。
为了加深理解,请思考下面的问题:
- 如果把synchronized(obj)放在for循环的外面,如何理解?那么线程t1会将5000次循环一次执行完毕,中间不会被其它线程执行;
- 如果t1 synchronized(obj1)而t2 synchronized(obj2)会怎样运作?不会生效,因为不是同一把锁,没有办法保证互斥,即保证同一时刻只有一个线程可以访问临界资源;
- 如果t1 synchronized(obj)而t2没有加会怎么样?如何理解?不会生效,因为t2当访问临界资源的时候(counter—),不会因为没获得对象锁而被收到限制,所以很顺利地执行count—,这样仍会造成多个线程访问临界资源而指令序列混乱的问题;
四、锁对象面向对象改进
public class TestOmmSuo{
public static void main(String[] args) throws InterruptedException {
Room room =new Room();
Thread t1 = new Thread(()->{
for(int i=0;i<5000;i++){
room.increment();
}
},"t1");
Thread t2 =new Thread(()->{
for(int i=0;i<5000;i++){
room.decrement();
}
},"t1");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("counter值:"+room.getCounter());
}
}
class Room{
private int counter = 0;//原来的静态共享变量,现在转为某个类的成员变量
//因为counter仍是临界资源,所以原则是,对其访问就要上锁
public void increment(){
//对当前this对象加锁
synchronized (this){
counter--;
}
}
public void decrement(){
synchronized (this){
counter++;
}
}
public int getCounter(){
synchronized (this){
return counter;
}
}
}
解析:共享资源/临界资源从静态全局变量,转为类的成员变量,而且对临界资源访问就要加锁,所以下边的increment、decrement方法中访问counter时候要加锁,对象锁为该对象本身。
注意在主线程中创建room对象,那么t1、t2线程访问到的是同一个对象,相当于是用了一个对象锁,这样即保证了多线程的互斥访问;