部分内容来自以下博客:

1 多线程产生安全问题

1.1 原因

多个线程操作共享的数据。

一个线程在操作共享数据时,其他线程也操作了共享数据。

1.2 多售票窗口同时售票引发的安全问题

情景说明:

有2个售票窗口同时售卖3张车票,在这个情境中,用2个线程模拟2个售票窗口,3张车票是共享资源,可售卖的编号是1到3,从3号车票开始售卖。

如果在售票时没有考虑线程的并发问题,2个窗口都能同时修改车票资源,则很容易引发多线程的安全问题。

代码如下:

public class Demo {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(new Runnable() {
            @Override
            public void run() {
                ticket.sale();
            }
        }, "窗口1").start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                ticket.sale();
            }
        }, "窗口2").start();
    }
}

class Ticket {
    private int num = 3;

    public void sale() {
        while (num > 0) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " sale " + num--);
        }
    }
}

运行结果如下:

窗口1 sale 2
窗口2 sale 3
窗口1 sale 1
窗口2 sale 0

结果说明:

从结果中我们看到窗口1在最后一次售卖中,卖出了编号为0的车票,实际上是不存在的。

出现这种问题的原因是当车票还剩1张的时候,2个窗口同时判断车票数量是否大于1,这时2个窗口就同时进入了售票扣减的代码,导致本来只能卖出1张的车票被2个窗口各自卖出了1张,从而产生了不存在的车票。

在程序里产生这种问题一般都是因为时间片的切换导致的,当一个线程进入操作共享资源的代码块时,时间片用完,另一个线程也通过判断进入了同一个代码块,导致第二个线程在操作共享资源时,没有重新进行判断。也就是说线程对共享资源的操作时不完整的,中间有可能被其他线程对资源进行修改。

1.3 单例模式的线程安全问题

1.3.1 懒汉式存在线程安全问题

这种写法起到了延迟加载的效果,但是只能在单线程下使用。如果在多线程下,一个线程进入了判断语句块,还没来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例,所以在多线程环境下不可使用这种方式。

public class Singleton {
    private static Singleton singleton;

    private Singleton() {}

    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

为了解决线程安全问题,我们可以使用synchronized关键字来修饰获取线程的公有方法,但是这么做会导致每次都要进入到同步方法里判断一下,方法进行同步效率太低。

public class Singleton {
    private static Singleton singleton;

    private Singleton() {}

    public static synchronized Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

为了不需要每次都进行同步,可以使用双重锁定检查(DCL,Double Check Lock),只需要在创建的时候进入同步方法,以后只要判断已经存在实例就直接返回实例,不需要再次进入同步方法。

public class Singleton {
    private static volatile Singleton singleton;

    private Singleton() {}

    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

除了使用同步机制保证线程安全之外,还可以使用静态内部类来保证线程安全。

public class Singleton {
    private Singleton() {}

    private static class SingletonInstance {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonInstance.INSTANCE;
    }
}

这种方式跟饿汉式方式采用的机制类似,但又有不同。两者都是采用了类装载的机制来保证初始化实例时只有一个线程。不同的地方在饿汉式方式是只要Singleton类被装载就会实例化,没有延迟加载的作用,而静态内部类方式在Singleton类被装载时并不会立即实例化,而是在需要实例化时,调用getInstance方法,才会装载SingletonInstance类,从而完成Singleton的实例化。

类的静态属性只会在第一次加载类的时候初始化,所以在这里,JVM帮助我们保证了线程的安全性,在类进行初始化时,别的线程是无法进入的。

1.3.2 饿汉式不存在线程安全问题

饿汉式的写法比较简单,就是在类装载的时候就完成实例化,避免了线程同步问题。

但这样会导致在类加载时就进行了实例化,没有做到延迟加载,如果这个实例没有被用到,会造成内存浪费。

public class Singleton {
    private final static Singleton INSTANCE = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

2 使用synchronized关键字

synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。

synchronized的作用有三个:

1)确保线程互斥的访问同步代码。

2)保证共享变量的修改能够及时可见。

3)有效解决重排序问题。

从语法上讲,synchronized总共有三种用法:

1)修饰普通方法。

2)修饰静态方法。

3)修饰代码块。

接下来我就通过几个例子程序来说明一下这三种使用方式。

2.1 使用synchronized的同步代码块

使用synchronized关键字修饰的代码块将对共享资源的操作封装起来,当有一个线程运行代码块时,其他线程只能等待,从而避免共享资源被其他线程修改。

要求多个线程同步使用的锁都必须是同一个才能保证同步,常用的是使用一个Object对象,或者使用this,或者使用类的class对象。

代码如下:

public class Demo {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(new Runnable() {
            @Override
            public void run() {
                ticket.sale();
            }
        }, "窗口1").start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                ticket.sale();
            }
        }, "窗口2").start();
    }
}

class Ticket {
    private int num = 3;

    public void sale() {
        while (num > 0) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (Ticket.class) {
                if (num > 0) {
                    System.out.println(Thread.currentThread().getName() + " sale " + num--);
                }
            }
        }
    }
}

运行结果如下:

窗口2 sale 3
窗口1 sale 2
窗口2 sale 1

结果说明:

线程在进入卖票的代码块之前,先看一下当前是否由其他线程在执行代码块,如果有其他线程在执行代码块则会等待,直到其他线程执行完之后才能进入代码块,从而保证了线程并发的安全问题。

2.2 使用synchronized的普通同步方法

将操作共享资源的代码封装为方法,添加synchronized关键字修饰,这个方法就是同步方法,使用的锁是this对象。

代码如下:

public class Demo {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(new Runnable() {
            @Override
            public void run() {
                ticket.sale();
            }
        }, "窗口1").start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                ticket.sale();
            }
        }, "窗口2").start();
    }
}

class Ticket {
    private int num = 3;

    public void sale() {
        while (num > 0) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            doSale();
        }
    }

    public synchronized void doSale() {
        if (num > 0) {
            System.out.println(Thread.currentThread().getName() + " sale " + num--);
        }
    }
}

运行结果如下:

窗口1 sale 3
窗口1 sale 2
窗口2 sale 1

结果说明:

在每次调用sale()方法售票的时候,程序会将实例对象this作为锁,保证一个时间只能有一个线程在操作共享资源。

2.3 使用synchronized的静态同步方法

如果该方法是静态方法,因为静态方法优先于类的实例化,所以静态方法是不能持有this的,静态同步方法的琐是类的class对象。

代码如下:

public class Demo {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(new Runnable() {
            @Override
            public void run() {
                ticket.sale();
            }
        }, "窗口1").start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                ticket.sale();
            }
        }, "窗口2").start();
    }
}

class Ticket {
    private static int num = 3;

    public void sale() {
        while (num > 0) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            doSale();
        }
    }

    public synchronized static void doSale() {
        if (num > 0) {
            System.out.println(Thread.currentThread().getName() + " sale " + num--);
        }
    }
}

运行结果如下:

窗口1 sale 3
窗口2 sale 2
窗口1 sale 1

结果说明:

使用静态同步方法除了需要注意共享资源也要用static修饰外,其他的和普通同步方法是一样的。

3 synchronized关键字和volatile关键字的区别

3.1 含义

volatile主要用在多个线程感知实例变量被更改了场合,从而使得各个线程获得最新的值。它强制线程每次从主内存中讲到变量,而不是从线程的私有内存中读取变量,从而保证了数据的可见性。

synchronized主要通过对象锁控制线程对共享数据的访问,持有相同对象锁的线程只能等其他持有同一个对象锁的线程执行完毕之后,才能持有这个对象锁访问和处理共享数据。

3.2 比较

3.2.1 量级比较

volatile轻量级,只能修饰变量。

synchronized重量级,还可修饰方法。

3.2.2 原子性

volatile不能保证原子性,不能用来同步,因为多个线程并发访问volatile修饰的变量不会阻塞。

synchronized可以保证原子性,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢synchronized锁对象时,会出现阻塞。

3.3 总结

要使用synchronized,必须要有两个以上的线程。单线程使用没有意义,还会使效率降低。

要使用synchronized,线程之间需要发生同步,不需要同步的没必要使用synchronized,例如只读数据。

使用synchronized的缺点是效率非常低,因为加锁、释放锁和释放锁后争抢CPU执行权的操作都很耗费资源。