OO-Unit2-Pre

线程同步
不需要锁的操作
当一个类是“线程安全”的,对该类的对象进行操作时,我们无需再进行线程同步。例如java.lang.StringBuffer
类已经被设计为线程安全的。
当一个类是不可变,我们无需对其进行线程同步。例如Stirng
,Integer
等类型。
当一个操作是原子操作,我们无需对该操作进行线程同步。
JVM规定了几种原子操作:
- 基本类型(
long
和double
除外)赋值,例如:int n = m
- 引用类型赋值,例如:
List<String> list = anotherList
当一个方法内的局部变量“没有逃逸”,我们无需对该变量进行线程同步。所谓没有逃逸,指如果一个局部变量(包括方法参数)在方法内部创建,并且其引用没有被传递给其他线程或存储在其他线程可见的地方,那么这个局部变量就只在当前线程的栈上,其他线程无法直接访问它。举例来说:
1 | void set(String[] names, int n) { |
局部变量step
与ns
在方法内被创建,创建的过程对于其他线程是不可见的,因此无需进行线程同步。但后续的赋值过程将局部变量赋值给了成员变量,并且其他线程能够访问这些成员变量,因此需要进行线程同步。
分析变量是否能够被多线程访问时,一个观察点是:多线程同步执行的是方法,如果该方法中有对于全局变量或成员方法的读、写操作,我们就应该考虑到,不同线程可能同时读取该变量,可能同时写该变量,也可能同时又写又读该变量。这时我们就应该对该方法(更精细的,对该方法中某一部分的读写操作)进行线程同步。
需要锁的操作与synchronized
对于非原子操作,需要进行线程同步,但在判断一个操作是否是原子操作时,可能有一些比较tricky的点
例如,我们知道Integer
的赋值操作是原子操作,但对于多个变量的赋值操作就不是原子操作了
1 | class Point{ |
要理解这点,我们需要一个读方法来配合。假设现在有两个线程对一个Point
对象进行操作,一个线程读,一个线程写,且他们的操作顺序是不可知的。那么,read x, read y, write x, write y
得到的(x,y)是x,y均未更新的数值对,write x, read x, read y, write y
得到的(x,y)是更新了x但为更新y的数值对。这就乱套了。
对方法上锁
1 | public class Counter{ |
以上两种写法是等价的,都是对this
上锁。那么如何理解对this
上锁呢?设想我们有两个Counter
对象,每个对象的add()
操作对于其他对象是没有影响的,因此我们允许多个线程并行的对他们进行操作。但对于同一个Counter
对象,假设我们还有其他对于成员变量cnt
的操作,那么多个线程对于同一个对象的同一个成员变量的操作是不被允许的,是需要进行线程同步的。
线程调度
wait and notify
在java中,可以使用wait()
和notify()
方法对多个线程进行协调调度。例如在生产者消费者模式中,你会希望消费者线程只在流水线上有物品时才进行消费操作,即在条件不满足时,线程需要被阻塞,当条件满足时,线程才被唤醒。
method | 作用 |
---|---|
wait() | 让活动在当前对象的线程无限等待,同时释放对象锁 |
notify() | 唤醒当前对象正在等待的线程 |
notifyAll() | 唤醒当前对象所有正在等待的线程 |
注意:notify()
方法仅会唤醒一个等待中的线程,具体唤醒那个具有随机性。那么这可能导致一些非常“有趣”的问题。
1 | public class Producer implements Runnable{ |
运行以上代码,你会发现程序很快就进入了死锁状态。这是因为以上代码使用了notify()
方法,假设producer1
线程在生产完一个object
之后唤醒了另一个生产者producer2
,由于队列是非空的,那么producer2
会继续进入等待,导致再也没有线程会被唤起。解决方法也很简单,即将以上代码中的两处notify()
改为notifyAll()
。你可能会问,所有线程都被唤醒了,那不会产生冲突吗?并不会,因为有锁的存在,即使两个消费者都被唤醒,他们必须去争夺一个List
锁,获得List
锁的线程才有机会去进行remove
操作;而如果另一个生产者争夺到了锁,他会由于while
语句而重新进入等待状态,并且释放锁。
Semaphore
信号量可以控制同时访问某一对象的进程数量,提供了一种简洁高效的调度机制。
由于电梯是生产者-消费者模式,因此在这里就以生产者-消费者模式为例,探讨信号量的使用。
生产者-消费者模式有两个核心问题:互斥访问、条件同步。互斥访问也就是说读操作和写操作必须是原子的,避免数据竞争。条件同步,具体来说指生产者不能在缓冲区满时继续生产,消费者不能在缓冲区空时继续消费。
为了解决条件同步,我们设置两个信号量,emptySlots
表示缓冲区中可用的空位数量(初始值为缓冲区容量),filledSlots
表示缓冲区中已填充的数据数量(初始值为0)。另外对于互斥访问,需要设置一个初始值为1的mutex
信号量,每次写入或读取操作前,都需要aquire
该信号量。借助这三个信号量,我们可以清晰的描述生产者和消费者的逻辑:
生产者:
- 申请空位:
emptySlots.aquire()
(如果没有空位需要等待消费者释放空位) - 写入
- 释放填充位:
filledSlots.release()
(即通知消费者可以进行消费)
消费者:
- 申请填充位:
filledSlots.aquire()
(如果没有填充位需要等待生产者释放填充位) - 读取
- 释放空位:
emptySlots.release()
(即通知生产者可以进行生产)
读写锁
事实上,我们可以允许多个线程对同一个对象在同一时间进行读操作。在读多写少的场景下,允许读操作的并发执行将极大提升程序的效率
具体来说,读锁是可以共享的,写锁只能是独占的。
使用方法非常简单,只需要创建一个ReadWriteLock
实例,然后从同一个实例中获取读锁和写锁即可。
1 | public class RWTest { |
锁升级与降级
锁升级指获得读锁后直接升级为写锁,这一操作是不被允许的。一种解决方案是显示释放读锁后获取写锁,但可能需要考虑间隙期间的数据竞争问题。如果线程A释放读锁后线程B获得了写锁且修改了数据,之后线程A再获取写锁,这时线程A可能会基于已经过时的数据进行写操作。因此这种解决方案是比较麻烦的。
另一种方法是,在预计到之后需要进行写操时,直接获取写锁而非读锁,在持有写锁期间内进行读写操作。
锁降级指持有写锁期间先获取读锁,然后再释放写锁。这种操作,能更高效的读取刚刚写入的数据。
1 | lock.writeLock().lock(); |
- Title: OO-Unit2-Pre
- Author: OWPETER
- Created at : 2025-03-23 10:18:54
- Updated at : 2025-03-23 10:20:26
- Link: https://owpeter.github.io/2025/03/23/OO-Unit2-Pre/
- License: This work is licensed under CC BY-NC-SA 4.0.