一、多线程的数据不一致
当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。
这个时候,一个在单线程模型下不存在的问题就会发生:如果多个线程同时读写共享变量,会出现数据不一致的问题,所以必须保证是原子操作。原子操作是指不能被中断的一个或一系列作。
通过加锁和解锁的操作,即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical section),任何时候临界区最多只有一个线程能执行。
二、synchronized关键字
保证一段代码的原子性就是通过加锁和解锁实现的。在Java 的多线程模型中使用 synchronized 关键字对一个对象进行加锁。
解决多线程并发执行时的线程同步问题(不安全、不同步),例如:解决多线程递增&&递减:
方式一:使用synchronized代码块
public class Main {
public static void main(String[] args) throws InterruptedException {
// 创建并启动2个线程,分别执行加和减的操作
Thread add = new AddThread();
Thread dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter1.count);
}
}
class Counter1 extends Thread {
public static int count = 0;
// 创建Object对象,用于实现同步锁
public final static Object LOCK = new Object();
}
class AddThread extends Thread {
public void run() {
for (int i = 0; i < 100; i++) {
synchronized (Counter1.LOCK) { // 加锁
Counter.count += 1;
} // 释放锁
}
}
}
class DecThread extends Thread {
public void run() {
for (int i = 0; i < 100; i++) {
synchronized (Counter1.LOCK) { // 加锁
Counter1.count -= 1;
}
} // 释放锁
}
}
方式二:使用this对象作为锁
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter2 cou = new Counter2();
// 创建并启动2个线程,分别执行加和减的操作
Thread add = new Thread(()->{
cou.add();
});
Thread dec = new Thread(()->{
cou.dec();
});
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter2.count);
}
}
class Counter2{
public static int count=0;
// 递增
// 在方法声明上使用synchronized关键字,对整个方法体进行加锁,使用this对象作为锁
public synchronized void add(){
for (int i=0;i<100;i++){
Counter2.count+=1;
}
}
// 递减
// 作用同步
public void dec(){
synchronized (this){
for (int i=0;i<100;i++){
Counter2.count-=1;
}
}
}
}
方式三:使用Class对象作为锁
public class Main {
public static void main(String[] args) throws InterruptedException {
// 创建并启动2个线程,分别执行加和减的操作
Thread add = new Thread(() -> {
Counter3.add();
});
Thread dec = new Thread(() -> {
Counter3.dec();
});
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter3.count);
}
}
class Counter3 {
public static int count = 0;
// 在静态方法声明上使用synchronized关键字,对整个方法体进行加锁
// 使用Class对象作为锁
public synchronized static void add() {
for (int i = 0; i < 100; i++) {
Counter3.count += 1;
}
}
// 作用等同
public static void dec() {
synchronized (Counter3.class) {
for (int i = 0; i < 100; i++) {
Counter3.count -= 1;
}
}
}
}
方式四:使用具备原子性操作的参数类型
public class Main {
public static void main(String[] args) throws InterruptedException {
// 创建并启动2个线程,分别执行加和减的操作
Thread add = new Thread(() -> {
Counter4.add();
});
Thread dec = new Thread(() -> {
Counter4.dec();
});
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter4.count);
}
}
class Counter4 {
// 具备原子性操作的AtomicInteger
public static AtomicInteger count = new AtomicInteger(0);
public static void add() {
for (int i = 0; i < 100; i++) {
Counter4.count.incrementAndGet(); // 自增+1
}
}
public static void dec() {
for (int i = 0; i < 100; i++) {
Counter4.count.decrementAndGet(); // 自减-1
}
}
}
使用 synchronized 解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为 synchronized 代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized 会降低程序的执行效率。
概括总结一下如何使用 synchronized:
- 找出修改共享变量的线程代码块
- 选择一个共享实例作为锁
- 使用 synchronized(lockobject){ ... }
三、注意事项
1.抛出异常
在使用 synchronized 的时候,不必担心抛出异常。因为无论是否有异常,都会在 synchronized 结束处正确释放锁。
2.不同的lock
使用 synchronized 的时候,获取到的是哪个锁非常重要。锁对象如果不对,代码逻辑就不对。
3.不需要synchronized的操作
JVM 规范定义了几种原子操作:
- 基本类型( long 和 double 除外)赋值,例如:intn=m;
long和 double是64 位数据,JVM 没有明确规定 64 位赋值操作是不是一个原子操作,不过在 x64 平台的 JVM 是把 long 和 double 的赋值作为原子操作实现的。
- 引用类型赋值,例如:List<string>list=anotherList。
注意:单条原子操作的语句不需要同步,但是,如果是多行赋值语句,就必须保证是同步操作。
四、案例
1.多线程售票
public class TicketPool implements Runnable{
// 当前剩余门票数
private int ticketNum;
// 创建公共票池,传入默认总门票数
public TicketPool(int ticketNum){
this.ticketNum=ticketNum;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"准备开始卖票");
// 线程竞争CPU执行权(this锁)
synchronized(Thread.currentThread()){
while (true){
if (ticketNum<=0){
System.out.println(Thread.currentThread().getName()+"卖完了");
return;
} else{
System.out.println(Thread.currentThread().getName()+"卖了一张票,还剩"+(--ticketNum)+"张票");
}
try {
// 休眠过程中:当前线程不会让出持有的"this锁",此处为引发错误的原因
// Thread.sleep(1000); // 不会释放锁
// 注意:等待过程中,当前线程让出持有的"this锁",允许其它线程参与竞争CPU执行权(this锁)
this.wait(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
public class SaleTicket {
public static void main(String[] args) throws InterruptedException {
// 创建一个公共票池
TicketPool ticketPool = new TicketPool(50);
// 模拟三个售票窗口
Thread t1 = new Thread(ticketPool);
Thread t2 = new Thread(ticketPool);
Thread t3 = new Thread(ticketPool);
t1.start();
t2.start();
t3.start();
}
}
2.多线程打印数字+字母
Character类打印字母:
public class Character implements Runnable{
private Object lock;
public Character(Object lock){
this.lock=lock;
}
@Override
public void run() {
synchronized (lock){
for (char i='A';i<='Z';i++){
// 输出当前字母
System.out.print(i);
// "唤醒" 数字线程
lock.notify(); // notifyAll 唤醒所有线程
if (i<'Z'){
try {
// 字母线程,每打印一个字母,进入等待状态
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
Number类打印数字:
public class Number implements Runnable {
private Object lock;
public Number(Object lock) {
this.lock = lock;
}
@Override
public void run() {
synchronized (lock) {
for (int i = 1; i < 53; i++) {
// 有两个数字+字母之间输出1个空格,用于分隔
if (i % 2 == 1) {
System.out.print(" ");
}
// 输出当前数字
System.out.print(i);
// 当前数字是偶数,需要进入等待状态
if (i % 2 == 0) {
// "唤醒" 字母线程
lock.notify(); // notifyAll 唤醒所有线程
try {
lock.wait(); //当前线程进入等待状态,并释放锁,( 被唤醒后,继续从等待的地方接着执行)
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
测试类:
public class Test {
public static void main(String[] args) {
// 公共锁对象
final Object LOCK = new Object();
// 创建两个线程,分别执行Number和Character,共用“一把锁”
Thread t1 = new Thread(new Number(LOCK));
Thread t2 =new Thread(new Character(LOCK));
t1.start();
t2.start();
}
}
五、总结
- 多线程同时读写共享变量时,可能会造成逻辑错误,因此需要通过synchronized 同步;
- 同步的本质就是给指定对象加锁,加锁后才能继续执行后续代码;
- 注意加锁对象必须是同一个实例;
- 对 JVM 定义的单个原子操作不需要同步。