为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  • 阻塞式的解决方案:synchronized、Lock
  • 非阻塞式的解决方案:原子变量,每次对共享资源的读写操作不可再被细分,所以不存在指令执行混乱,这种方法从根本上解决了竞态条件的发生;

使用synchronized解决上述问题,即俗称的【对象锁】,它采用互斥的方式让只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】就会被阻塞住,从而陷入阻塞态,这个【对象锁】实际上就是对共享资源上锁,这样可以使多线程一次只有一个线程能够访问共享资源。这样就能保证拥有锁的线程可以安全地执行临界区的代码,不用担心线程上下文切换,因为即使切换到其它线程,其它线程根本没有权限访问资源(无锁)
本质上讲,是保证临界区对于线程的串行执行。

  1. ![image.png](https://cdn.nlark.com/yuque/0/2021/png/23190196/1638364924029-010e9577-d4b4-4b42-8316-ea09ee3a4f82.png#clientId=u260bcc91-2d42-4&from=paste&height=147&id=u10a5537e&margin=%5Bobject%20Object%5D&name=image.png&originHeight=261&originWidth=938&originalType=binary&ratio=1&size=79404&status=done&style=none&taskId=u1a434de3-1c85-4ce8-ade0-9b90e8de687&width=527.9873657226562)

一、synchronized语法

  1. synchronized(Object obj){ //线程1,线程2(blocked)
  2. 临界区
  3. }

括号中的对象即为对象锁,获得该对象锁才能对临界区代码进行访问,线程2会进入阻塞态,直到线程1释放该对象锁,线程2被唤醒,进入就绪状态,可以争夺锁,当拿到对象锁后临界区内代码针对所有线程来说就是串行执行的。

用synchronized解决上述共享问题的代码:

  1. public class TestSynchronized {
  2. static int counter = 0;//临界资源(共享资源)
  3. static Object lock = new Object();//需要被上锁的对象,可以理解成无意义的对象,只是为了实现互斥条件;
  4. public static void main(String[] args) {
  5. Thread t1 =new Thread(()->{
  6. for (int i=0;i<5000;i++){
  7. synchronized (lock){
  8. counter++;
  9. }
  10. }
  11. },"t1");
  12. Thread t2 =new Thread(()->{
  13. for (int i=0;i<5000;i++){
  14. synchronized (lock){//实际可以理解为:只有拿到对该lock对象的锁,才能执行方括号下边的代码
  15. counter--;
  16. }
  17. }
  18. },"t2");
  19. //t1线程和t2线程启动
  20. t1.start();
  21. t2.start();
  22. System.out.println("counter值为:"+counter);
  23. }
  24. }

注意,这里的lock对象实际可以是任何对象,只是把它叫做“对象锁”

  1. synchronized(lock){
  2. counter--;
  3. }

上述对象可以理解为:只有线程拿到对象锁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—代码;

用图来表示:
image.png

如图,线程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—,这样仍会造成多个线程访问临界资源而指令序列混乱的问题;

四、锁对象面向对象改进

  1. public class TestOmmSuo{
  2. public static void main(String[] args) throws InterruptedException {
  3. Room room =new Room();
  4. Thread t1 = new Thread(()->{
  5. for(int i=0;i<5000;i++){
  6. room.increment();
  7. }
  8. },"t1");
  9. Thread t2 =new Thread(()->{
  10. for(int i=0;i<5000;i++){
  11. room.decrement();
  12. }
  13. },"t1");
  14. t1.start();
  15. t2.start();
  16. t1.join();
  17. t2.join();
  18. System.out.println("counter值:"+room.getCounter());
  19. }
  20. }
  21. class Room{
  22. private int counter = 0;//原来的静态共享变量,现在转为某个类的成员变量
  23. //因为counter仍是临界资源,所以原则是,对其访问就要上锁
  24. public void increment(){
  25. //对当前this对象加锁
  26. synchronized (this){
  27. counter--;
  28. }
  29. }
  30. public void decrement(){
  31. synchronized (this){
  32. counter++;
  33. }
  34. }
  35. public int getCounter(){
  36. synchronized (this){
  37. return counter;
  38. }
  39. }
  40. }

解析:共享资源/临界资源从静态全局变量,转为类的成员变量,而且对临界资源访问就要加锁,所以下边的increment、decrement方法中访问counter时候要加锁,对象锁为该对象本身。
注意在主线程中创建room对象,那么t1、t2线程访问到的是同一个对象,相当于是用了一个对象锁,这样即保证了多线程的互斥访问;