"锁"一直是Java中很重要的部分,对锁的掌握可以说是Java程序员是否及格的重要检测标准。此次我们先从最基础的Synchronized聊起,一起看看Synchronized作用在方法上,在不同类型的变量上都有哪些效果。本文重在将能想到的情况逐一列举,尽可能全的描述Synchronized不同场景下的不同作用,所以案例避免不了有所重复,还请同学耐心读完,在锁的地方多看几个例子也是值得的。
本文是在《线程八锁》相关文章的基础上做了扩展。将会通过以下几点来讲述Synchronized的应用案例。
如何理解Synchronized
Synchronized直译过来为"同步,使协调",可以帮助我们解决多线程问题,所谓多线程问题可以理解为多个线程操作同一个共享资源而导致的诸多疑难杂症。
比如有一个int类型的共享变量a,初始值是0,此时线程A和线程B都要给a增加1,那么根据JVM工作的机制,两个线程首先将共享变量a从主存拷贝到自己工作的内存中。正常情况下,线程A执行a = a+1之后将a写回到主存,此时线程B再获得a的值为1,执行a = a+1 后得到结果为2,再更新到主存,这个过程没有问题,a的结果是2。可线程A和B从主存拷贝a值的实际我们并没有加以控制,很可能两个线程同时获得了a的值为0,两个线程分别计算a = a+1后都得到1更新到主存,此时主存中的结果为1,跟我们预期的2是不同的。
导致问题的根本原因就是两个线程工作无组织无纪律,没有按照我们预期的一个线程操作共享变量结束另一个线程再操作,所以需要给线程们立个规矩来保证这一点。Synchronized给线程们立的规矩就很简单有效: 设置一个标志位,有线程操作共享数据时就在标志位上写上自己的名字,操作完毕后再将名字擦掉。线程在访问变量时就知道是否有人已经在操作了,如果有就等一等,没有就直接操作。标志位的记录方式可以是以账本的形式操作计1和操作完毕计-1分别标记,两者之和等于0则为没有人在操作,也可以是其他形式;其他线程在等待时可以是排队也可以是PK决定下一个操作者,不同的记录和等待形式决定了不同的锁类型,本次我们主要聊一聊标志记录在哪里的问题。
要谨记记录同一个共享变量是否在操作的标记目的是让其他线程看到,所以要在操作和操作完毕需要记录在同一个地方,不然线程A在自己弄了个账本记录上”A在操作“,线程B无从得知则不能遵守规则。
下面来看不同的案例,首先构建一个简单的多线程代码。在下面的代码中,Num类拥有两个方法getOne()和getTwo(),两个方法都是简单的输出,创建一个Num对象在两个线程中分别调用这两个方法。
class TestSynchronized{
public static void main(String[] args) {
Num num1 = new Num();
new Thread(() -> {
num1.getOne();
},"t1").start();
new Thread(() -> {
num1.getTwo();
},"t2").start();
}
}
class Num {
public void getOne(){
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("sleep 3s");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("One");
}
public void getTwo(){
System.out.println("Two");
}
}
此时就是简单的线程中调用对象方法,也还没有涉及到Synchronized,所以两个线程相互不影响,输出结果没有疑问应该是Two和sleep 3s后输出One。
下面我们尝试通过不同的方式设置标志位,来实现让线程出现执行分先后的情况。
1 - 锁普通对象
可以在Num类中定义一个Object对象当做两个线程的”记账本“。注意,我们只需要给线程提供记录标记的地方,标记的记录和擦除他们自己会完成。此时代码更改如下。
为了区分不同的执行结果,我们在getOne()方法中增加线程sleep 3s的代码以作区别。
class TestSynchronized{
public static void main(String[] args) {
Num num1 = new Num();
new Thread(() -> {
num1.getOne();
},"t1").start();
new Thread(() -> {
num1.getTwo();
},"t2").start();
}
}
class Num {
//新增一个object对象当做标记为记录的媒介
Object obj = new Object();
public void getOne(){
//方法体内新增Synchronized代码块
synchronized (obj){
//sleep 3s
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("sleep 3s");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("One");
}
}
public void getTwo(){
//方法体内新增Synchronized代码块
synchronized (obj){
System.out.println("Two");
}
}
}
此时执行代码,执行结果为:
可以看到Synchronized生效了,线程t2在t1执行完毕后才输出Two。此处需要注意一个细节,obj是一个类成员变量,也就是说每个不同的Num对象都拥有自己单独obj,如果我们在t1中调用num1.getOne()而在t2中调用num2.getTwo(),显然Synchronized是不会生效的,因为"账本不是一个"。代码验证如下:
class TestSynchronized{
public static void main(String[] args) {
Num num1 = new Num();
//新创建一个Num对象
Num num2 = new Num();
new Thread(() -> {
num1.getOne();
},"t1").start();
new Thread(() -> {
//此处调用num2的方法
num2.getTwo();
},"t2").start();
}
}
class Num {
Object obj = new Object();
public void getOne(){
synchronized (obj){
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("sleep 3s");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("One");
}
}
public void getTwo(){
synchronized (obj){
System.out.println("Two");
}
}
}
执行结果如下
跟我们预想的一样,Two直接输出,并没有等待t1执行完毕。不同的对象作为"账本"不能限制线程按"规矩"办事。
2 - 锁this
既然上边用普通对象都可以完成锁的功能,那我们调用方法的对象是不是直接能用了,就不用再单独创建一个对象了。代码更改如下
class TestSynchronized{
public static void main(String[] args) {
Num num1 = new Num();
new Thread(() -> {
num1.getOne();
},"t1").start();
new Thread(() -> {
num1.getTwo();
},"t2").start();
}
}
class Num {
//去掉单独创建的对象
//Object obj = new Object();
public void getOne(){
//换成锁this
synchronized (this){
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("sleep 3s");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("One");
}
}
public void getTwo(){
//换成锁this
synchronized (this){
System.out.println("Two");
}
}
}
执行结果如下
可以看到锁是生效的。这里我们锁的是当前对象,也就是说只要是当前对象调用的方法体带Synchronized关键字的方法都会按"规矩"执行,这种写法下,getOne()和getTwo()是不会并发执行的。所以我们可以将Synchronized关键字提到方法修饰符上。Synchronized锁this等价于锁方法本身。代码测试
class TestSynchronized{
public static void main(String[] args) {
Num num1 = new Num();
new Thread(() -> {
num1.getOne();
},"t1").start();
new Thread(() -> {
num1.getTwo();
},"t2").start();
}
}
class Num {
//synchronized修饰方法
public synchronized void getOne(){
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("sleep 3s");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("One");
}
//synchronized修饰方法
public synchronized void getTwo(){
System.out.println("Two");
}
}
执行结果如下
可以看到还是通过num1在不同的线程中调用不同方法,num1.getOne() 和 num1.getTwo() ,执行效果同锁this是一样的。验证了上边的Synchronized锁this等同于锁方法。
3 - 锁静态变量
普通成员变量对象和调用方法的对象本身都可以用来当做锁,但一个类的两个不同实例的普通成员变量是两个相互独立的对象,不能共享,this也拥有同样的问题。如何能锁住同类的两个不同对象呢?我们知道static修饰的成员变量本身是所有对象共享的,一个类的static变量只在内存中存储一份,那static变量作为锁是否可以让不同类对象的方法调用按"规矩"办事呢?我们来测试一下
class TestSynchronized{
public static void main(String[] args) {
Num num1 = new Num();
Num num2 = new Num();
new Thread(() -> {
num1.getOne();
},"t1").start();
//两个线程使用两个不同的Num类实例
new Thread(() -> {
num2.getTwo();
},"t2").start();
}
}
class Num {
//增加一个静态变量
static Object obj = new Object();
public void getOne(){
//使用静态变量加锁
synchronized (obj){
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("sleep 3s");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("One");
}
}
public void getTwo(){
//使用静态变量加锁
synchronized (obj){
System.out.println("Two");
}
}
}
执行结果
可以看到在同一个类不同实例的情况下,使用静态成员作为上锁的对象,锁是成立的。验证了我们前边的推断。那毫无疑问,两个线程在使用同一个实例的情况下对静态成员加锁,锁一定也是有效的,此处不做验证。
4 - 锁类对象
既然被所有本类实例公用的static成员可以做到跨实例加锁,那我们推断,没个类自己的类对象也是可以做到的。因为每个类只有一个类实例,也就是Num.class.代码验证如下,因为类对象的获取方式可以是this.getClass(),也可以是Num.class,所以我们两个方法故意使用不同的获取方式来验证。
class TestSynchronized{
public static void main(String[] args) {
Num num1 = new Num();
Num num2 = new Num();
new Thread(() -> {
num1.getOne();
},"t1").start();
//两个线程使用两个不同的Num类实例
new Thread(() -> {
num2.getTwo();
},"t2").start();
}
}
class Num {
public void getOne(){
//使用类对象
synchronized (Num.class){
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("sleep 3s");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("One");
}
}
public void getTwo(){
//使用类对象
synchronized (this.getClass()){
System.out.println("Two");
}
}
}
执行结果
可以看到锁是生效的。不过到此处,大家可能还会有一个疑问。既然static变量可以当做类的全局锁,class对象也可以当做全局锁,那这两者之前是否是相同的呢?要验证起来也很简单,两个方法分别使用静态变量和class的加锁方式放到两个线程中执行,如果先输出了Two则静态变量锁和class对象锁不是同一把锁,也就是两种加锁方式的锁记录不在同一个地方相互无关。我们来验证下。
class TestSynchronized{
public static void main(String[] args) {
Num num1 = new Num();
Num num2 = new Num();
new Thread(() -> {
num1.getOne();
},"t1").start();
//两个线程使用两个不同的Num类实例
new Thread(() -> {
num2.getTwo();
},"t2").start();
}
}
class Num {
//还是增加一个static变量
static Object obj = new Object();
public void getOne(){
//使用类对象
synchronized (Num.class){
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("sleep 3s");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("One");
}
}
public void getTwo(){
//使用静态变量
synchronized (obj){
System.out.println("Two");
}
}
}
执行结果
可以看到锁是不生效的,也就是静态变量和类对象虽然同样是全局锁,但不是同一把锁。而且经过测试,不同的静态变量上加的锁都不是同一把锁,所以静态变量除了全局锁的效果总体还是遵循普通变量的加锁规则。因为静态变量虽然是全局共享的,但不是全局唯一的。一个类可以有多个静态变量但类对象却是唯一的。静态方法是同类对象性质相似的全局共享且唯一的,那我们来测试下再静态方法上加锁和在类对象上加锁是否效果相同。
class TestSynchronized{
public static void main(String[] args) {
Num num1 = new Num();
Num num2 = new Num();
new Thread(() -> {
num1.getOne();
},"t1").start();
//两个线程不同对象,测试全局锁是不是生效
new Thread(() -> {
num2.getTwo();
},"t2").start();
}
}
class Num {
//静态对象加锁
public static synchronized void getOne(){
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("sleep 3s");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("One");
}
public void getTwo(){
//class对象加锁
synchronized (this.getClass()){
System.out.println("Two");
}
}
}
执行结果
可以看到,静态方法锁和class对象锁是同一把锁。两者等价。
5 - 锁String类型对象
String是Java中比较特殊的类,因为它在内存中维护了一个常量池,用来做String对象实例的存储。该类在创建实例的时候会首先检查常量池中是否有了要创建的String,若没有则创建,有则引用原来的。我们看下边的代码
class TestString{
public static void main(String[] args) {
String s1 = " ";
String s2 = " ";
System.out.println("String 类型两个实例作比较 : " + (s1 == s2));
Num n1 = new Num();
Num n2 = new Num();
System.out.println("Num 类型两个实例作比较 : " + (n1 == n2));
}
}
执行结果
对象之间使用"=="做比较,比较的是内存地址。也就是说s1 和 s2是实实在在的就是一个对象,指向同一块内存地址,而且这个内存所在地方式方法区,是存放公用数据的位置。那么是不是说两个方法使用相同的字符串锁定,也能实现同类下所有实例加锁呢?
class TestSynchronized{
public static void main(String[] args) {
Num num1 = new Num();
Num num2 = new Num();
new Thread(() -> {
num1.getOne();
},"t1").start();
//两个线程使用两个不同的Num类实例
new Thread(() -> {
num2.getTwo();
},"t2").start();
}
}
class Num {
public void getOne(){
//使用String
synchronized (" "){
try {
TimeUnit.SECONDS.sleep(3);
System.out.println("sleep 3s");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("One");
}
}
public void getTwo(){
//使用String
synchronized (" "){
System.out.println("Two");
}
}
}
执行结果
可以看到,我们的猜想是正确的。String类型因为其自身的特殊性,确实可以做到普通对象锁全局的效果。既然不同对象实例都可以加锁,那同一个对象在不同线程使用String类型加锁一定是有效的,此处不再验证。
总结
经过上面的代码实践我们可以总结出,Synchronized锁可以加在普通变量,静态变量,this,类对象和String类型对象上,其中加载this上等同于非静态方法加锁,加在类对象上等同于静态方法加锁,String类型变量加锁做到了普通对象(还是有点不普通的)实现全局锁的效果。还有几个反例,比如static变量和类变量不是同一把锁,两个static变量上加锁也不是同一把锁,一个类的两个实例拥有各自的非静态变量,在非静态变量上加锁不是同一把锁也不会生效。
这些就是我们常用的Synchronized关键字使用场景,我觉得还是挺全的。
Synchronized在多线程中虽然至关重要,但也是人们畏之如虎的关键字,因为在Java的早期版本,Synchronized是特别重量级别的锁,可以说使用了Synchronized就等同于牺牲掉了并发度。但是在后边的JDK中,对Synchronized做了优化。另一部分包括其他种类的锁等内容我们放到后边文章中讨论,还请看下回分解。
感谢您的耐心阅读~