[Java并发] 1. 线程同步(同步器)

[Java并发] 1. 线程同步(同步器)

一、synchronized关键字

对于synchronized的理解:
由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类。

1. 给对象加锁
1.1 new一个对象作为锁

synchronized(Object)对括号内的对象加锁,任何线程要执行synchronized代码块中的代码,都必须要先拿到该对象的锁,当代码块执行完毕时,锁就会释放,被其他线程获取。

public class T {
    private int count = 10;
    private final Object lock = new Object();	// 锁对象    
    public void m() {
        synchronized (lock) { // 任何线程要执行下面的代码,都必须先拿到lock锁,锁信息记录在堆内存对象中的,不是在栈引用中
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
        // 当上述synchronized代码块执行完毕后,锁就会被释放,然后被其他线程获取
    }   
}

注意:synchronized(lock)是锁住堆内存中的lock指向的Object对象,而引用变量lock是位于栈内存中的。

1.2 直接锁定自身对象

在1.1中,每次使用锁都新建一个毫无其他功能的锁对象比较麻烦,因此我们可以直接对this对象加锁,即synchronized(this)

public class T {
    private int count = 10;
        public void m() {
        synchronized (this) { // 任何线程要执行下面的代码,必须先拿到this锁
            // synchronized锁定的不是代码块,而是this对象
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
    }
}
1.3 synchronized修饰方法

若整个方法内所有代码都被synchronized修饰,则可以使synchronized关键字修饰整个方法。

public class T {
    private int count = 10;
    public synchronized void m() { // 等同于synchronized(this),锁定当前堆内存对象 
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }    
}	

1.4 synchronized锁定静态方法

类中静态方法和静态属性属性不需要new一个对象就可以访问,没有new出来,就没有this引用的存在,所以当锁定一个静态方法时,相当于锁定的是当前类的class对象。

public class T {
    private static int count = 10;
    public static synchronized void m() {//等同于synchronized(T.class) 
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }    
    // 上边m()方法与下边mm()方法等价
    public static synchronized void mm() {
        synchronized (T.class) { 
            // 这里不能使用synchronized(this),因为静态方法不需要实例对象即可访问
        	count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
    }   
}
1.5. synchronized锁住线程的run方法
public class T implements Runnable{
    private int count = 10;    
    @Override
    public /*synchronized*/ void run() {
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }
    public static void main(String[] args) {
        T t = new T();	 
        for (int i = 0; i < 5; i++) {
            new Thread(t).start();	//这里new的所有线程的锁住的是同一个上边的t对象
        }
    }
}

run方法不加synchronized:因为不保证原子性,每个线程在执行count--和输出操作之间,可能有别的线程来执行count--,导致前后数据不一致。
加上synchronized关键字:相当于是一个原子操作,一个run方法执行完毕释放了锁,下一个线程才能拿到锁执行run方法。

2. synchronized对方法加锁, 同步方法和非同步方法是否可以同时调用
public class T {

	public synchronized void m1() {
		System.out.println(Thread.currentThread().getName() + "m1 start...");
		try {
			Thread.sleep(10000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println(Thread.currentThread().getName() + "m1 end...");
	}
	
	public void m2() {
		try {
			Thread.sleep(5000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println(Thread.currentThread().getName() + "m2 ...");
	}
	
	public static void main(String[] args) {
		T t = new T();
		
		new Thread(() -> t.m1(), "t1").start();//Java8中的lamba表达式
		new Thread(() -> t.m2(), "t2").start();
		
//		new Thread(t::m1, "t1").start(); //更简洁的写法
//		new Thread(t::m2, "t2").start();

		/**
		new Thread(new Runnable() {  //最原始的写法
			@Override
			public void run() {
				t.m1();
			}
		}).start();

		new Thread(new Runnable() {
			@Override
			public void run() {
				t.m2();
			}
		}).start();
		 */
	}
}

同步方法与非同步方法是可以同时调用的。只有synchronized修饰的方法在运行过程中才需要申请锁,普通方法是不需要申请的。在同步方法m1()执行的同时,非同步方法m2()也在执行

3. 脏读问题

业务代码中,对业务写方法加锁,而对业务读方法不加锁,容易产生脏读问题(dirty read)。
脏读,不可重复读,幻读

import java.util.concurrent.TimeUnit;
public class Account {
	String name;
	double balance;//账户余额为成员变量 默认为0.0
	
	public synchronized void set(String name, double balance) {//写操作
		this.name = name;
		//下面这段是为了放大在this.name = name与this.balance = balance的执行间可能有别的业务代码执行的情形,比如getbalance(),因为这里它是非锁定方法仍然可以访问name
		try {
			Thread.sleep(2000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}		
		this.balance = balance;
	}
	
	public /* synchronized */ double getBalance(String name) {//读操作
		return this.balance;
	}
	
	public static void main(String[] args) {
		Account a = new Account();
		new Thread(() -> a.set("张三", 100.0)).start();
		
		try {
			TimeUnit.SECONDS.sleep(1);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		System.out.println(a.getBalance("zhangsan"));//0.0
		
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		System.out.println(a.getBalance("zhangsan"));//100.0
	}
}

set方法在初始化时会休眠2s,我们调用set方法后1s读取余额,显示为0,再过2s后余额才变为100.0,因此允不允许脏读?要根据实际业务场景斟酌使用

4. synchronized是可重入锁
4.1 一个同步方法可以调用另一个同步方法

一个同步方法可以调用另外一个同步方法:若一个线程已抢到某对象的锁,再申请时仍然会得到该对象的锁。因为这是在同一个线程以内,无非就是给锁上的数字加一(同一线程,同一把锁)

import java.util.concurrent.TimeUnit;
public class T implements Runnable {
	@Override
	public synchronized void run() {
		System.out.println("m1 start...");
		try {
			TimeUnit.SECONDS.sleep(1);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		m2();// 在同步方法m1()中调用同步方法m2(),不会发生死锁,因为这是在同一线程内的调用
		System.out.println("m1 end");
	}
	
	synchronized void m2() {
		System.out.println("m2 start");
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println("m2 end");
	}

	public static void main(String[] args) {
		T t = new T();
		new Thread(t::run).start();
	}
}

程序输出如下,没有发生死锁,且m1()方法会等待m2()方法结束后继续运行,说明这是函数调用,而非线程并行。

m1 start 
m2 start
m2 end
m1 end
4.2 子类的同步方法可以调用父类的同步方法

子类的同步方法可以调用父类的同步方法也不会发生死锁,两个方法锁住的this指向的都是同一个子类对象。

import java.util.concurrent.TimeUnit;
public class T {
	// 父类同步方法
    synchronized void m2() {
        System.out.println("father method start");
        System.out.println("father method lock:" + this);
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("father method end");
    }
}

class TT extends T {
    // 子类同步方法
    @Override
    synchronized void m1() {
        System.out.println("child method start");
        System.out.println("child method lock:" + this);
        super.m();
        System.out.println("child method end");
    }

    public static void main(String[] args) {
        TT tt = new TT();
        new Thread(tt::m1).start();
    }
}

程序输出结果如下,没有发生死锁,且m1()方法会等待m2()方法结束后继续运行,说明这是函数调用,而非线程并行; 另外也可以看到父子的同步方法持有的是同一把锁。

child method start
child method lock:thread01.TT@2dd5c6ac
father method start
father method lock:thread01.TT@2dd5c6ac
father method end
child method end
5. 出现异常默认情况锁会被释放

synchronized修饰的代码块中出现异常,线程进行异常处理后会马上释放锁(与ReentrantLock正相反).

import java.util.concurrent.TimeUnit;
public class T {
    int i = 0;
    // 同步方法,计数到5抛出异常
    synchronized void m() {
        System.out.println(Thread.currentThread().getName() + " start");
        while (true) {
            i++;
            System.out.println(Thread.currentThread().getName() + ": " + i);
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }			
            // 计数到5抛出异常
            if (i == 5) {
                int error = 1 / 0;
            }
        }
    }

    public static void main(String[] args) {
        T t = new T();
        new Thread(t::m, "线程1").start();
        new Thread(t::m, "线程2").start();
    }
}

程序执行结果:线程1抛出异常后马上释放锁,锁被线程2抢到并开始执行。

解决方法: 使用try-catch捕获异常。

try{
    if (i == 5) {
        int error = 1 / 0;
    }
}catch(Exception e){
    System.out.println("除法溢出");
}

6. 引用变量指向对象的改变对锁的影响

synchronized锁住的是堆中o对象的实例,而不是o对象的引用,synchronized是针对堆中o对象的实例进行计数。

  1. 若在程序运行过程中,,引用o指向对象的属性发生改变,锁状态不变。
  2. 若在程序运行过程中,引用o指向的对象发生改变,则锁状态改变,原本抢到的锁作废,线程会去抢新锁。因此实际编程中常将锁对象的引用用final修饰,保证其指向的锁对象不发生改变。(final修饰引用时,该引用所指向的属性可以改变,但该引用不能再指向其他对象)
public class T {
    Object o = new Object();

    // 该方法锁住的o对象引用没有被设为final
    void m() {
        synchronized (o) {
            while (true) {
                System.out.println(Thread.currentThread().getName() + "正在运行");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        T t = new T();
        new Thread(t::m, "线程1").start();

        // 在这里让程序睡一会儿,保证两个线程得到的o对象不同
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread thread2 = new Thread(t::m, "线程2");

        // 改变锁引用,使得线程2也有机会运行,否则一直都是线程1运行
        t.o = new Object();
        thread2.start();
    }
}

程序输出如下,看到主线程睡了3秒之后,线程1和线程2交替运行,他们各自抢到了不同的锁

线程1正在运行
线程1正在运行
线程1正在运行
线程2正在运行
线程1正在运行
线程2正在运行
线程1正在运行
线程2正在运行
...

如果没有改变锁引用,将会一直是线程1在运行。

7. 不要将字符串常量作为锁定对象

因为字符串常量池的存在,两个不同的字符串引用可能指向同一字符串对象。

public class T {

    // 两个字符串常量,作为两同步方法的锁
    String s1 = "Hello";
    String s2 = "Hello";

    // 同步m1方法以s1为锁
    void m1() {
        synchronized (s1) {
            while (true) {
                System.out.println(Thread.currentThread().getName() + ":m1 is running");
            }
        }
    }

    // 同步m2方法以s2为锁
    void m2() {
        synchronized (s2) {
            while (true) {
                System.out.println(Thread.currentThread().getName() + ":m1 is running");
            }
        }
    }

    public static void main(String[] args) {
        T t = new T();
		
        // 输出两个锁的哈希码
        System.out.println(t.s1.hashCode());
        System.out.println(t.s2.hashCode());

        new Thread(t::m1, "线程1").start();
        new Thread(t::m2, "线程2").start();
    }
}

程序执行结果如下,实际上m1m2其实锁定的是同一对象,即两个字符串常量指向的是同一对象,有一个线程永远得不到锁。

69609650
69609650
线程1:m1 is running
线程1:m1 is running
线程1:m1 is running
线程1:m1 is running
线程1:m1 is running
线程1:m1 is running 

这种情况还会发生比较诡异的现象,比如你用到一个类库,在该类库中代码锁定了字符串 “Hello”,但是你看不到源码,然后你在自己代码中也锁定了 “Hello”, 这时就会发生非常诡异的死锁阻塞,因为你的程序和使用到的类库不经意间使用了同一把锁。

8. 同步代码中的语句越少越好
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
 * synchronized的优化
 * 同步代码中的语句越少越好
 * 比较m1和m2
 */
public class T {
	
	int count = 0;
	
	synchronized void m1() {
		// do something need not sync
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		// 业务逻辑中只有下面这句需要sync,这时不应该给整个方法上锁
		count ++;
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	
    void m2() {
		// do something need not sync
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		// 业务逻辑中只有下面这句需要sync,这时不应该给整个方法上锁
		// 采用细粒度的锁, 可以使用线程争用时间变短,从而提高效率
		synchronized(this) {
			count ++;
		}
		// do something need not sync
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	
	public static void main(String[] args) {
		T t = new T();
		List<Thread> threads = new ArrayList<Thread>();
		
		for (int i = 0; i < 10; i ++) {
			threads.add(new Thread(t::m1, "thread-" + i));
		}
		
		threads.forEach((o) -> o.start());
		
		threads.forEach((o) -> {
			try {
				o.join();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		});
		
		System.out.println(t.count);
	}
}

二、volatile关键字

volatile关键字, 是一个变量在多个线程间可见

1. volatile的可见性

volatile关键字向编译器声明该变量是易变的,每次对volatile关键字的修改会通知给所有相关线程.

  1. 在Java内存模型JMM中,所有对象以及信息都存放在主内存中(包含堆,栈),而每个线程在CPU中都有自己的独立空间,存储了需要用到的变量的副本。
  2. 线程对共享变量的操作,都会先在自己CPU中的工作内存中进行,然后再同步给主内存。若不加volatile关键字修饰,每个线程都有可能直接从自己CPU中的工作内存读取内存,这样如果另一个线程修改了原变量,该线程却未必知道,从而引起同步问题;而加以volatile关键字修饰后,每个线程对该变量进行修改后都会马上通知给所有线程。

下面的程序中,running变量存在于主内存的t对象中,当线程t1开通的时候, 会把running值从内存中读到t1线程的工作区,在运行中直接使用这个copy,并不会每次都去读取内存,这样, 当主线程修改running的值后,t1线程感知不到, 所以不会停止运行。

使用volatile, 将会强制所有线程都去对内存中读取running的值, 缓存过期通知

import java.util.concurrent.TimeUnit;
public class T {
	/**
	 * https://www.cnblogs.com/Mushrooms/p/5151593.html
	 *
	 * 补充内容:分享点儿知识,内容就是CPU内部的寄存器。就这个程序来说,有两个线程。一个是主线程,
	 * 一个是自己启动的线程。当自己启动的线程运行时,running这个变量的值会被CPU把值从内存中读到
	 * CPU中的寄存器(即CPU中的cache)中。为什么这么做呢?因为CPU的速度要比内存的速度快,内存的速
	 * 度比硬盘快。所以要把running中的数据copy一份到内存中处理。但是,没有加volatile关键字的变
	 * 量running,当主线程已经把running改为false,自己启动的线程依然不能停下来。因为它读的是CPU
	 * 中running。主线程改的内存中的running。两个线程读写的变量的存储位置不同。
	 *
	 * 而volatile关键字就是为了解决这个问题而出现的。其作用是,当主线程对内存中的变量running修改
	 * 后,就会通知CPU中的变量running,你那个值已经不是最新的了。这时候,自己启动的线程会重新读一
	 * 遍内存中的running变量。
	 */

    volatile boolean running = true; //对比一下有无volatile的情况下,整个程序运行结果的区别
    
    void m() {
        System.out.println("m start");
        while (running) { 
            //死循环。只有running=false时,才能执行后面的语句
        }
        System.out.println("m end");
    }

    public static void main(String[] args) {
        T t = new T();
        new Thread(t::m, "t1").start();
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 将running变量设为false,观察线程是否被终止
        t.running = false;
    }
}

运行结果表明,如果不对running变量加以volatile修饰,则对running``变量的修改不能终止子线程,说明在主线程中对running`的修改对子线程不可见.

但是如果在while死循环体中加入一些语句或sleep一段时间之后,可见性问题可能会消失,这是因为加入语句后,CPU就可能会出现空闲,并同步主内存中的内容到工作内存,但这是不确定的,因此在这种情况下还是尽量要加上volatile

2. volatile不保证原子性

volatile只能保证可见性,但不能保证原子性。 即只会在读变量的操作进行检查,不会检查写回变量的时候之前读入变量的值是否已经被修改。

volatile不能解决多个线程同时修改一个变量带来的线程安全问题, 也就是说volatile不能代替synchronized

/*10个线程分别执行10000次count++,count是对象t的成员变量,按理来说最终count=100000,
  但是最终每次执行结果都不一样,count一直小于100000,说明volatile不具备原子性*/
import java.util.ArrayList;
import java.util.List;
public class T {
	volatile int count = 0;
	void m() {
		for (int i = 0; i < 10000; i++) {
			count ++;//++操作不具备原子性
		}
	}
	
	public static void main(String[] args) {
		T t = new T();
		List<Thread> threads = new ArrayList<Thread>();		
		for (int i = 0; i < 10; i ++) {
			threads.add(new Thread(t::m, "thread-" + i));
		}		
		threads.forEach((o) -> o.start());		
		threads.forEach((o) -> {
			try {
				//join()方法阻塞调用此方法的线程,直到线程t完成,此线程再继续。通常用于在main()主线程内,等待其它线程完成再结束main()主线程。
				o.join();//相当于在main线程中同步o线程,o执行完了,main线程才有执行的机会
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		});		
		System.out.println(t.count);
	}
}
使用synchronized保证原子性和可见性

使用synchronized解决,输出count为10000。

int count = 0;
synchronized void m() {  //m方法加了synchronized修饰,保证了原子性和可见性
    for (int i=0; i<10000; i++) {
        count ++ ;
    }
}
更高效:使用AtomicXXX类

AtomXXX类本身方法都是原子性的, 但不能保证多方法连续调用的原子性。

import java.util.concurrent.atomic.AtomicInteger;

	AtomicInteger count = new AtomicInteger(0);
	
	/*synchronized*/ void m() { //不需要加锁了
		for (int i = 0; i < 10000; i++) {
			// 如果加上了if (count.get() < 1000)
			// 则在for循环两条语句中间,即这个位置是没有原子性的
			count.incrementAndGet(); // 具备原子性,用来替换count++;
		}
	}
3. volatile与synchronized关键字区别
  • volatile 只能保证可见性,效率高
  • synchronized 既保证可见性,有保证原子性,效率低
4. 面试题:监控容器内元素个数

题目:写两个线程,线程1添加10个元素到容器中,线程2实时监控元素的个数,当容器中元素个数达到5时,线程2给出提示并立即结束

思路:容器选用ArrayList<Object>,调用其add()方法添加元素,调用size()方法得到容器中元素个数。

方法1 volatile

线程2一直轮询,将容器设为volatile保证线程间可见性,使线程2可以收到通知。

public class MyContainer {
	// 主要容器,设为volatile保证线程间可见性
    private volatile List<Object> list = new ArrayList<>();	
    public void add(Object ele) {
        list.add(ele);
    }
    public int size() {
        return list.size();
    }

    public static void main(String[] args) {
        MyContainer container = new MyContainer();
        // 线程1,每隔一秒向容器中添加一个元素
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                container.add(new Object());
                //这个部分可能被线程2抢占
                System.out.println("add " + i);
                // 每隔一秒添加一个元素
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                
            }
        }, "线程1").start();
        // 线程2,轮询容器内元素个数
        new Thread(() -> {
            while (true) {
                if (container.size() == 5) {
                	//这个部分可能被线程1抢占
                    break;
                }
            }
            System.out.println("监测到容器长度为5,线程2立即退出");
        }, "线程2").start();
    }
}

评价:

  • 不够精确: 若当container.size == 5还未执行break时,被其他线程抢占;或container.add()之后还未打印,就被线程2抢占并判断到container.size == 5并退出了。
  • 损耗性能: 线程2一直在走while(true)循环,浪费性能。我们避免用到死循环。
方法2 wait/notifyAll

使用wait/notify机制,当线程1写入5个元素后通知线程2。

① 运用这种方法,必须保证t2先执行,先让t2监听才可以
② wait会释放锁, 而notify与sleep不会释放锁
③ 因此notify之后,t1必须释放锁, t2退出后,也必须notify, 通知t1继续执行

锁的转移过程:

  • 先启动线程2并使主线程睡2秒以确保线程2先抢到锁。
  • 线程2抢到锁后调用wait(),让其释放锁并阻塞,以确保线程1获得锁。
  • 线程1抢到锁后开始向容器内添加元素。当线程1添加了5个元素后调用notify()通知线程2并调用wait()释放锁并阻塞,以确保线程2获得锁。
  • 线程2抢到锁后输出语句并退出,退出之前调用notify()唤醒线程1,因为线程2退出后会释放锁,因此这时不用调用wait()释放锁。
public class MyContainer {
    // 主要容器,因为只有线程1对其进行修改和查询操作,所以不用加volatile关键字
    private List<Object> list = new ArrayList<>();
    public void add(Object ele) {
        list.add(ele);
    }
    public int size() {
        return list.size();
    }

    public static void main(String[] args) {
        MyContainer container = new MyContainer();
        
        final Object lock = new Object();    // 锁对象
        
        // 线程2先启动并进入wait状态,等待被线程1唤醒
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("线程2启动");
                if (container.size() != 5) {
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("监测到容器长度为5,线程2立即退出");
                // 线程1唤醒线程2后立刻睡眠了,因此线程2退出前要再次唤醒线程1
                lock.notify();
                System.out.println("线程2结束");
            }
        }, "线程2").start();

        // 主线程睡2秒钟再创建线程1,确保线程2先得到锁
		try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 线程1,每隔一秒向容器中添加一个元素
        new Thread(() -> {
            synchronized (lock) {
                for (int i = 0; i < 10; i++) {
                    container.add(new Object());
                    System.out.println("add " + i);
                    // 当容器中元素个数达到5时,唤醒线程2并退出线程1
                    if (container.size() == 5) {
                        lock.notify();
                        // notify()方法不会释放锁,因此即使通知了线程2,也不能让线程2立刻执行
                        // 所以要先将线程1 wait()住,让其释放锁给线程2,等待线程2退出前再通知唤醒线程1
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    // 每隔一秒添加一个元素
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }, "线程1").start();
    }
}

评价:当不涉及同步,只涉及线程通信的时候,用synchronized+wait/notify机制就显得太重了,实际编程中常用封装层次更深的类库实现线程间通信.

方法3 CountDownLatch

使用门闩锁CountDownLatch类锁住线程2,并等待线程1撤去门闩释放线程2。

public class MyContainer {

    // 主要容器,因为门闩锁只是一种同步方式,不保证可见性,因此需要用volatile修饰
    private volatile List<Object> list = new ArrayList<>();
    public void add(Object ele) {
        list.add(ele);
    }
    public int size() {
        return list.size();
    }

    public static void main(String[] args) {
        MyContainer container = new MyContainer();

        // 门闩锁,构造函数中传入门闩数,使用其countDown()方法撤掉一条门闩
        // 当门闩数为0时,门会打开,两个线程都会被执行
        CountDownLatch latch = new CountDownLatch(1);

        // 线程2先启动并调用await()让其被门闩锁锁住
        new Thread(() -> {
            System.out.println("线程2启动");
            if (container.size() != 5) {
                try {
                    // 让线程被门闩锁锁住,等待门闩的开放,而不是进入等待队列
                    latch.await();
                    // 可以指定等待时间
					// latch.await(5000, TimeUnit.MILLISECONDS)
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("监测到容器长度为5,线程2立即退出");
        }, "线程2").start();

        // 主线程睡2秒钟再创建线程1,确保线程2先得到锁
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 线程1,每隔一秒向容器中添加一个元素
        new Thread(() -> {
            System.out.println("线程1 启动");
            for (int i = 0; i < 10; i++) {
                container.add(new Object());
                System.out.println("add " + i);
                // 当容器中元素个数达到5时,撤去一个门闩,打开门闩锁,两个线程都会被执行
                if (container.size() == 5) {
                    latch.countDown();
                }
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "线程1").start();
    }
}

评价:

  • 门闩锁不涉及锁定,当count的值为5时线程1并不会停止运行。
  • 使用Latch(门闩)的awaitcountdown方法代替wait, notify来进行通知,通信方式简单, 同时可以指定等待时间。
  • 当不涉及同步,只有涉及线程通信的时候,用synchronized + wait/notify就太重了,这时应该考虑使用CountDownLatch/cyclicbarrier/semaphore

门闩锁CountDownLatch在框架中使用的非常广泛,如在Spring框架中,要先实例化所有PropertiesService对象后才能实例化Bean对象。因此我们给初始化Bean对象的线程上一个两道门闩的门闩锁,初始化完毕所有Properties对象后撤去一道门闩,初始化完毕所有Service对象后再撤去一道门闩,两道门闩撤去后,门闩锁打开,创建Bean的线程开始执行。

三、ReentrantLock可重入锁

1. ReentrantLock替代synchronized

ReentrantLock可以完全替代synchronized,提供了一种更灵活的锁。
ReenTrantLock必须手动释放锁,为防止发生异常,必须将同步代码用try包裹起来,在finally代码块中释放锁。

public class T {
    ReentrantLock lock = new ReentrantLock();
    // 使用ReentrantLock的写法
    private void m1() {
        // 尝试获得锁
        lock.lock(); //等于synchronized(this)
        try {
            System.out.println(Thread.currentThread().getName());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock(); 
        }
    }

    // 使用synchronized的写法
    private synchronized void m2() {
        System.out.println(Thread.currentThread().getName());
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        T t = new T();
        new Thread(t::m1, "t1").start(); 
        new Thread(t::m2, "t2").start(); 
    }
}
2. ReentrantLock获取锁的方法
2.1 尝试锁tryLock()
  • 使用tryLock()方法可以尝试获得锁,返回一个boolean值,指示是否获得锁。

  • 可以给tryLock方法传入阻塞时长,当超出阻塞时长时,线程退出阻塞状态转而执行其他操作。

public class T {
    ReentrantLock lock = new ReentrantLock();

    void m() {
        boolean isLocked = false;        // 记录是否得到锁

        // 改变下面两个量的大小关系,观察输出
        int synTime = 4;   	 // 同步操作耗时
        int waitTime = 2;    // 获取锁的等待时间

        try {
            isLocked = lock.tryLock(waitTime, TimeUnit.SECONDS);    // 线程在这里阻塞waitTime秒,尝试获取锁
            if (isLocked) {
                // 若waitTime秒内得到锁,则执行同步操作
                for (int i = 1; i <= synTime; i++) {
                    TimeUnit.SECONDS.sleep(1);
                    System.out.println(Thread.currentThread().getName() + "持有锁,执行同步操作");
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 使用tryLock()方法,尝试解除标记时,一定要先判断当前线程是否持有锁
            if (isLocked) {
                lock.unlock();
            }
        }
        // 执行非同步操作
        while (true) {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "没持有锁,执行非同步操作");
        }
    }

    public static void main(String[] args) {
        T t = new T();
        new Thread(t::m, "线程1").start();
        new Thread(t::m, "线程2").start();
    }
}
  1. 设置同步操作耗时4秒,获取锁的等待时间为2秒,输出结果显示线程2在阻塞时间内没能抢到锁,直接执行非阻塞方法。

    线程1持有锁,执行同步操作
    线程1持有锁,执行同步操作
    线程2没持有锁,执行非同步操作
    线程1持有锁,执行同步操作
    线程2没持有锁,执行非同步操作
    线程1持有锁,执行同步操作
    线程2没持有锁,执行非同步操作
    线程1没持有锁,执行非同步操作
    线程2没持有锁,执行非同步操作
    线程1没持有锁,执行非同步操作
    ...
    
  2. 设置同步操作耗时4秒,获取锁的等待时间为5秒,输出结果显示线程2在阻塞时间内成功抢到锁,先执行完同步方法才执行非同步方法。

    线程1持有锁,执行同步操作
    线程1持有锁,执行同步操作
    线程1持有锁,执行同步操作
    线程1持有锁,执行同步操作
    线程2持有锁,执行同步操作
    线程1没持有锁,执行非同步操作
    线程2持有锁,执行同步操作
    线程1没持有锁,执行非同步操作
    线程2持有锁,执行同步操作
    线程1没持有锁,执行非同步操作
    线程2持有锁,执行同步操作
    线程1没持有锁,执行非同步操作
    线程2没持有锁,执行非同步操作
    ....
    
2.2 可中断锁lockInterruptibly()

使用lockInterruptibly()以一种可被中断的方式获取锁。获取不到锁时线程进入阻塞状态,但这种阻塞状态可以被中断。调用被阻塞线程的interrupt()方法可以中断该线程的阻塞状态,并抛出InterruptedException异常。

interrupt()方法只能中断线程的阻塞状态.若某线程已经得到锁或根本没去尝试获得锁,则该线程当前没有处于阻塞状态,因此不能被interrupt()方法中断.

public static void main(String[] args) {
    ReentrantLock lock = new ReentrantLock();

    // 线程1一直占用着lock锁
    new Thread(() -> {
        lock.lock();
        try {
            System.out.println("线程1启动");
            TimeUnit.SECONDS.sleep(Integer.MAX_VALUE);// 线程一直占用锁
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }, "线程1").start();

    // 线程2抢不到lock锁,若不被中断则一直被阻塞
    Thread t2 = new Thread(() -> {
        try {
            lock.lockInterruptibly();  // 尝试获取锁,若获取不到锁则一直阻塞
            System.out.println("线程2启动");
        } catch (InterruptedException e) {
            System.out.println("线程2阻塞过程中被中断");
        } finally {
            if (lock.isLocked()) {
                try {
                    lock.unlock(); // 没有锁定进行unlock就会抛出IllegalMonitorStateException异常
                } catch (Exception e) {
                }
            }
        }
    }, "线程2");
    t2.start();

    // 4秒后中断线程2
    try {
        TimeUnit.SECONDS.sleep(4);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    t2.interrupt();//告诉t2别等了,抛出异常
}

输出:

线程1启动
线程2阻塞过程中被中断

并不是所有处于阻塞状态的线程都可以被interrupt()方法中断,要看该线程处于具体的哪种阻塞状态。阻塞状态包括普通阻塞、等待队列、锁池队列。

  • 普通阻塞: 调用sleep()方法的线程处于普通阻塞,调用其interrupt()方法可以中断其阻塞状态并抛出InterruptedException异常。
  • 等待队列: 调用锁的wait()方法将持有当前锁的线程转入等待队列,这种阻塞状态只能由锁对象的notify()方法唤醒,而不能被线程的interrupt()方法中断。
  • 锁池队列: 尝试获取锁但没能成功抢到锁的线程会进入锁池队列:
    • 争抢synchronized锁的线程的阻塞状态不能被中断。
    • 使用ReentrantLock的lock()方法争抢锁的线程的阻塞状态不能被中断。
    • 使用ReentrantLocktryLock()lockInterruptibly()方法争抢锁的线程的阻塞状态可以被中断。

关于interrupted()方法的使用,可以查看这篇文章Java中interrupt的使用,总结来说,就是interrupt()方法不能打断线程,但是会给该线程发送一个interrupt信号,让该线程自己决定如何处理该信号,但有一种特殊情况:若该线程正处于阻塞状态,调用其interrupt()方法会抛出InterruptedException.

2.3 公平锁

公平锁:谁等的时间长,谁获得锁

在初始化ReentrantLock时给其fair参数传入true,可以指定该锁为公平锁。默认的synchronized为非公平锁。

CPU默认的进程调度是不公平的,也就是说,CPU不能保证等待时间较长的线程先被执行。但公平锁可以保证等待时间较长的线程先被执行。

public class T implements Runnable {
    private static ReentrantLock lock = new ReentrantLock(true);// 指定锁为公平锁

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + "持有锁");
            } finally {
                lock.unlock(); 
            }
        }
    }

    public static void main(String[] args) {
        T t = new T();
        new Thread(t, "线程1").start();
        new Thread(t, "线程2").start();
    }
}

程序输出发现两个线程严格交替执行。

四、ThreadLocal 线程局部变量

/*ThreadLocal是使用空间换时间,synchronized是使用时间换空间。
* 比如在Hibernate中的session就存在于ThreadLocal中,避免Synchronized的使用
* 线程局部变量属于每个线程都有自己的,线程间不共享,互不影响*/
public class ThreadLocalTest {
    static ThreadLocal<Person> tL = new ThreadLocal<>(); //每个线程的tL互不影响
 
    public static void main(String[] args) {
        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(tL.get());
        }).start();
 
        new Thread(()-> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            tL.set(new Person());
        }).start();
    }
 
    static class Person {
        String name = "zhangsan";
    }
}

第二个线程设置了值,但是第一个线程get得到的是null,说明线程局部变量是互不影响的。

整理自视频:马士兵老师java多线程高并发编程

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章