头条高级面试题:请谈谈Redis 9种数据结构以及它们的内部编码实现

Posted 阿飞的博客

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了头条高级面试题:请谈谈Redis 9种数据结构以及它们的内部编码实现相关的知识,希望对你有一定的参考价值。

90%的人知道Redis 5种最基本的数据结构,只有不到10%的人知道8种基本数据结构(5种基本+bitmap+GeoHash+HyperLogLog),只有不到5%的人知道9种基本数据结构(5.0最新版本数据结构Streams),只有不到1%的人掌握了所有9种基本数据结构以及8种内部编码,掌握这篇文章的知识点,让你成为面试官眼中Redis方面最靓的仔

说明:本文基于Redis-3.2.11版本源码进行分析。

5种普通数据结构

这个没什么好说的,对Redis稍微有点了解的都知道5种最基本的数据结构:String,List,Hash,Set,Sorted Set。不过,需要注意的是,这里依然有几个高频面试题。

  • Set和Hash的关系

  • 答案就是Set是一个特殊的value为空的Hash。Set类型操作的源码在t_set.c中。以新增一个元素为例(int setTypeAdd(robj *subject, sds value)),如果编码类型是OBJ_ENCODING_HT,那么新增源码的源码如下,事实上就是对dict即Hash数据结构进行操作,并且dictSetVal时value是NULL:

    dictEntry *de = dictAddRaw(ht,value,NULL);
    if (de) 
        dictSetKey(ht,de,sdsdup(value));
        dictSetVal(ht,de,NULL);
        return 1;

    同样的,我们在t_hash.c中看到Hash类型新增元素时,当判断编码类型是OBJ_ENCODING_HT时,也是调用dict的方法:dictAdd(o->ptr,f,v),dictAdd最终也是调用dictSetVal()方法,只不过v即value不为NULL:

    /* Add an element to the target hash table */
    int dictAdd(dict *d, void *key, void *val)

        dictEntry *entry = dictAddRaw(d,key,NULL);

        if (!entry) return DICT_ERR;
        dictSetVal(d, entry, val);
        return DICT_OK;

    所以,Redis中Set和Hash的关系就很清楚了,当编码是OBJ_ENCODING_HT时,两者都是dict数据类型,只不过Set是value为NULL的特殊的dict。

  • 谈谈你对Sorted Set的理解

  • Sorted Set的数据结构是一种跳表,即SkipList,如下图所示,红线是查找10的过程:

    SkipList
  • 如何借助Sorted set实现多维排序

  • Sorted Set默认情况下只能根据一个因子score进行排序。如此一来,局限性就很大,举个栗子:热门排行榜需要按照下载量&最近更新时间排序,即类似数据库中的ORDER BY download_count, update_time DESC。那这样的需求如果用Redis的Sorted Set实现呢?

    事实上很简单,思路就是将涉及排序的多个维度的列通过一定的方式转换成一个特殊的列,即result = function(x, y, z),即x,y,z是三个排序因子,例如下载量、时间等,通过自定义函数function()计算得到result,将result作为Sorted Set中的score的值,就能实现任意维度的排序需求了。可以参考笔者之前的文章:《Redis高级玩法:如何利用SortedSet实现多维度排序》。

    Redis内部编码

    我们常说的String,List,Hash,Set,Sorted Set只是对外的编码,实际上每种数据结构都有自己底层的内部编码实现,而且是多种实现,这样Redis可以在合适的场景选择更合适的内部编码。

    如下图所示(图片纠正:intset编码,而不是inset编码),可以看到每种数据结构都有2种以上的内部编码实现,例如String数据结构就包含了raw、int和embstr三种内部编码。同时,有些内部编码可以作为多种外部数据结构的内部实现,例如ziplist就是hash、list和zset共有的内部编码,而set的内部编码可能是hashtable或者intset:

    Redis内部编码

    Redis这样设计有两个好处:

    1. 可以偷偷的改进内部编码,而对外的数据结构和命令没有影响,这样一旦开发出更优秀的内部编码,无需改动对外数据结构和命令。

    2. 多种内部编码实现可以在不同场景下发挥各自的优势。例如ziplist比较节省内存,但是在列表元素比较多的情况下,性能会有所下降。这时候Redis会根据配置选项将列表类型的内部实现转换为linkedlist。

    String的3种内部编码

    由上图可知,String的3种内部编码分别是:int、embstr、raw。int类型很好理解,当一个key的value是整型时,Redis就将其编码为int类型(另外还有一个条件:把这个value当作字符串来看,它的长度不能超过20)。如下所示。这种编码类型为了节省内存。Redis默认会缓存10000个整型值(#define OBJ_SHARED_INTEGERS 10000),这就意味着,如果有10个不同的KEY,其value都是10000以内的值,事实上全部都是共享同一个对象:

    127.0.0.1:6379set number "7890"
    OK
    127.0.0.1:6379object encoding number
    "int"

    接下来就是ebmstr和raw两种内部编码的长度界限,请看下面的源码:

    #define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
    robj *createStringObject(const char *ptr, size_t len) 
        if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
            return createEmbeddedStringObject(ptr,len);
        else
            return createRawStringObject(ptr,len);

    也就是说,embstr和raw编码的长度界限是44,我们可以做如下验证。长度超过44以后,就是raw编码类型,不会有任何优化,是多长,就要消耗多少内存:

    127.0.0.1:6379set name "a1234567890123456789012345678901234567890123"
    OK
    127.0.0.1:6379object encoding name
    "embstr"
    127.0.0.1:6379set name "a12345678901234567890123456789012345678901234"
    OK
    127.0.0.1:6379object encoding name
    "raw"

    那么为什么有embstr编码呢?它相比raw的优势在哪里?embstr编码将创建字符串对象所需的空间分配的次数从raw编码的两次降低为一次。因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw编码的字符串对象能更好地利用缓存带来的优势。并且释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码对象的字符串对象需要调用两次内存释放函数。如下图所示,左边是embstr编码,右边是raw编码:

    embstr V.S. raw
    ziplist

    由前面的图可知,List,Hash,Sorted Set三种对外结构,在特殊情况下的内部编码都是ziplist,那么这个ziplist有什么神奇之处呢?

    以Hash为例,我们首先看一下什么条件下它的内部编码是ziplist:

    1. 当哈希类型元素个数小于hash-max-ziplist-entries配置(默认512个);

    2. 所有值都小于hash-max-ziplist-value配置(默认64个字节);

    如果是sorted set的话,同样需要满足两个条件:

    1. 元素个数小于zset-max-ziplist-entries配置,默认128;

    2. 所有值都小于zset-max-ziplist-value配置,默认64。

    实际上,ziplist充分体现了Redis对于存储效率的追求。一个普通的双向链表,链表中每一项都占用独立的一块内存,各项之间用地址指针(或引用)连接起来。这种方式会带来大量的内存碎片,而且地址指针也会占用额外的内存。而ziplist却是将表中每一项存放在前后连续的地址空间内,一个ziplist整体占用一大块内存。它是一个表(list),但其实不是一个链表(linked list)。

    ziplist的源码在ziplist.c这个文件中,其中有一段这样的描述 -- The general layout of the ziplist is as follows::

    <zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
  • zlbytes:表示这个ziplist占用了多少空间,或者说占了多少字节,这其中包括了zlbytes本身占用的4个字节;

  • zltail:表示到ziplist中最后一个元素的偏移量,有了这个值,pop操作的时间复杂度就是O(1)了,即不需要遍历整个ziplist;

  • zllen:表示ziplist中有多少个entry,即保存了多少个元素。由于这个字段占用16个字节,所以最大值是2^16-1,也就意味着,如果entry的数量超过2^16-1时,需要遍历整个ziplist才知道entry的数量;

  • entry:真正保存的数据,有它自己的编码;

  • zlend:专门用来表示ziplist尾部的特殊字符,占用8个字节,值固定为255,即8个字节每一位都是1。

  • 如下就是一个真实的ziplist编码,包含了2和5两个元素:

     [0f 00 00 00] [0c 00 00 00] [02 00] [00 f3] [02 f6] [ff]
           |             |          |       |       |     |
        zlbytes        zltail    entries   "2"     "5"   end
    linkedlist

    这是List的一种编码数据结构非常简单,就是我们非常熟悉的双向链表,对应Java中的LinkedList。

    skiplist

    这个前面也已经提及,就是经典的跳表数据结构。

    hashtable

    这个也很容易,对应Java中的HashMap。

    intset

    Set特殊内部编码,当满足下面的条件时Set的内部编码就是intset而不是hashtable:

    1. Set集合中必须是64位有符号的十进制整型;

    2. 元素个数不能超过set-max-intset-entries配置,默认512;

    验证如下:

    127.0.0.1:6379sadd scores 135
    (integer) 0
    127.0.0.1:6379sadd scores 128
    (integer) 1
    127.0.0.1:6379object encoding scores
    "intset"

    那么intset编码到底是个什么东西呢?看它的源码定义如下,很明显,就是整型数组,并且是一个有序的整型数组。它在内存分配上与ziplist有些类似,是连续的一整块内存空间,而且对于大整数和小整数采取了不同的编码,尽量对内存的使用进行了优化。这样的数据结构,如果执行SISMEMBER命令,即查看某个元素是否在集合中时,事实上使用的是二分查找法:

    typedef struct intset 
        uint32_t encoding;
        uint32_t length;
        int8_t contents[];
     intset;

    // intset编码查找方法源码(人为简化),标准的二分查找法:
    static uint8_t intsetSearch(intset *is, int64_t value, uint32_t *pos) 
        int min = 0, max = intrev32ifbe(is->length)-1, mid = -1;
        int64_t cur = -1;

        while(max >= min) 
            mid = ((unsigned int)min + (unsigned int)max) >> 1;
            cur = _intsetGet(is,mid);
            if (value > cur) 
                min = mid+1;
             else if (value < cur) 
                max = mid-1;
             else 
                break;
            
        

        if (value == cur) 
            if (pos) *pos = mid;
            return 1;
         else 
            if (pos) *pos = min;
            return 0;
        


    #define INTSET_ENC_INT16 (sizeof(int16_t))
    #define INTSET_ENC_INT32 (sizeof(int32_t))
    #define INTSET_ENC_INT64 (sizeof(int64_t))

    3种高级数据结构

    Redis中3种高级数据结构分别是bitmap、GEO、HyperLogLog,针对这3种数据结构,笔者之前也有文章介绍过。其中,最重要的就是bitmap

    bitmap

    这个就是Redis实现的BloomFilter,BloomFilter非常简单,如下图所示,假设已经有3个元素a、b和c,分别通过3个hash算法h1()、h2()和h2()计算然后对一个bit进行赋值,接下来假设需要判断d是否已经存在,那么也需要使用3个hash算法h1()、h2()和h2()对d进行计算,然后得到3个bit的值,恰好这3个bit的值为1,这就能够说明:d可能存在集合中。再判断e,由于h1(e)算出来的bit之前的值是0,那么说明:e一定不存在集合中

    BloomFilter

    需要说明的是,bitmap并不是一种真实的数据结构,它本质上是String数据结构,只不过操作的粒度变成了位,即bit。因为String类型最大长度为512MB,所以bitmap最多可以存储2^32个bit。

    GEO

    GEO数据结构可以在Redis中存储地理坐标,并且坐标有限制,由EPSG:900913 / EPSG:3785 / OSGEO:41001 规定如下:

    1. 有效的经度从-180度到180度。

    2. 有效的纬度从-85.05112878度到85.05112878度。

    当坐标位置超出上述指定范围时,该命令将会返回一个错误。添加地理位置命令如下:

    redisGEOADD city 114.031040 22.324386 "shenzhen" 112.572154 22.267832 "guangzhou"
    (integer) 2
    redisGEODIST city shenzhen guangzhou
    "150265.8106"

    但是,需要说明的是,Geo本身不是一种数据结构,它本质上还是借助于Sorted Set(ZSET),并且使用GeoHash技术进行填充。Redis中将经纬度使用52位的整数进行编码,放进zset中,score就是GeoHash的52位整数值。在使用Redis进行Geo查询时,其内部对应的操作其实就是zset(skiplist)的操作。通过zset的score进行排序就可以得到坐标附近的其它元素,通过将score还原成坐标值就可以得到元素的原始坐标。

    总之,Redis中处理这些地理位置坐标点的思想是:二维平面坐标点 --> 一维整数编码值 --> zset(score为编码值) --> zrangebyrank(获取score相近的元素)、zrangebyscore --> 通过score(整数编码值)反解坐标点 --> 附近点的地理位置坐标。

  • GEOHASH原理

  • 使用wiki上的例子,纬度为42.6,经度为-5.6的点,转化为base32的话要如何转呢?
    首先拿纬度来进行说明,纬度的范围为-90到90,将这个范围划为两段,则为[-90,0]、[0,90],然后看给定的纬度在哪个范围,在前面的范围的话,就设当前位为0,后面的话值便为1.然后继续将确定的范围1分为2,继续以确定值在前段还是后段来确定bit的值。就这样慢慢的缩小范围,一般最多缩小13次就可以了(经纬度的二进制位相加最多25位,经度13位,纬度12位)。这时的中间值,将跟给定的值最相近。如下图所示:

    Geohash

    第1行,纬度42.6位于[0, 90]之间,所以bit=1;第2行,纬度42.6位于[0, 45]之间,所以bit=0;第3行,纬度42.6位于[22.5, 45]之间,所以bit=1,以此类推。这样,取出图中的bit位:1011 1100 1001,同样的方法,将经度(范围-180到180)算出来为 :0111 1100 0000 0。结果对其如下:

    # 经度
    0111 1100 0000 0
    # 纬度
    1011 1100 1001

    得到了经纬度的二进制位后,下面需要将两者进行结合:从经度、纬度的循环,每次取其二进制的一位(不足位取0),合并为新的二进制数:01101111 11110000 01000001 0。每5位为一个十进制数,结合base32对应表映射为base32值为:ezs42。这样就完成了encode的过程。

    Streams

    这是Redis5.0引入的全新数据结构,这种数据结构笔者之前也有文章对其进行详细解读,链接地址:《Streams:深入剖析Redis5.0全新数据结构》,用一句话概括Streams就是Redis实现的内存版kafka。而且,Streams也有Consumer Groups的概念。通过Redis源码中对stream的定义我们可知,streams底层的数据结构是radix tree

    typedef struct stream 
        rax *rax;               /* The radix tree holding the stream. */
        uint64_t length;        /* Number of elements inside this stream. */
        streamID last_id;       /* Zero if there are yet no items. */
        rax *cgroups;           /* Consumer groups dictionary: name -> streamCG */
     stream;

    那么这个radix tree长啥样呢?在Redis源码的rax.h文件中有一段这样的描述,这样看起来是不是就比较直观了:

     *                    (f) ""
     *                    /
     *                 (i o) "f"
     *                 /   \\
     *    "firs"  ("rst")  (o) "fo"
     *              /        \\
     *    "first" []       [t   b] "foo"
     *                     /     \\
     *           "foot" ("er")    ("ar""foob"
     *                    /          \\
     *          "footer" []          [] "foobar"

    Radix Tree(基数树) 事实上就几乎相同是传统的二叉树。仅仅是在寻找方式上,以一个unsigned int类型数为例,利用这个数的每个比特位作为树节点的推断。能够这样说,比方一个数10001010101010110101010,那么依照Radix 树的插入就是在根节点,假设遇到0,就指向左节点,假设遇到1就指向右节点,在插入过程中构造树节点,在删除过程中删除树节点。如下是一个保存了7个单词的Radix Tree:

    radix tree


    END

    如果读完觉得有收获的话,欢迎点【好看】,关注【阿飞的博客】,查阅更多精彩历史!!!


    JAVA面试题 请谈谈你对Sychronized关键字的理解?

    面试官:sychronized关键字有哪些特性?

    应聘者:

    • 可以用来修饰方法;

    • 可以用来修饰代码块;

    • 可以用来修饰静态方法;

    • 可以保证线程安全;

    • 支持锁的重入;

    • sychronized使用不当导致死锁;

     

    了解sychronized之前,我们先来看一下几个常见的概念:内置锁、互斥锁、对象锁和类锁。

     

    内置锁

    在Java中每一个对象都可以作为同步的锁,那么这些锁就被称为内置锁。线程进入同步代码块或方法的时候会自动获得该锁,在退出同步代码块或方法时会释放该锁。获得内置锁的唯一途径就是进入这个锁的保护的同步代码块或方法。

     

    互斥锁

    内置锁同时也是一个互斥锁,这就是意味着最多只有一个线程能够获得该锁,当线程A尝试去获得线程B持有的内置锁时,线程A必须等待或者阻塞,直到线程B抛出异常或者正常执行完毕释放这个锁;如果B线程不释放这个锁,那么A线程将永远等待下去。

     

    对象锁和类锁

    对象锁和类锁在锁的概念上基本上和内置锁是一致的,但是,两个锁实际是有很大的区别的。

    • 对象锁是用于对象实例方法;

    • 类锁是用于类的静态方法或者一个类的class对象上的

    一个对象无论有多少个同步方法区,它们共用一把锁,某一时刻某个线程已经进入到某个synchronzed方法,那么在该方法没有执行完毕前,其他线程无法访问该对象的任何synchronzied 方法的,但可以访问非synchronzied方法。

    如果synchronized方法是static的,那么当线程访问该方法时,它锁的并不是synchronized方法所在的对象,而是synchronized方法所在对象的对应的Class对象,

    因为java中无论一个类有多少个对象,这些对象会对应唯一一个Class对象,因此当线程分别访问同一个类的两个对象的static,synchronized方法时,他们的执行也是按顺序来的,也就是说一个线程先执行,一个线程后执行。

     synchronized的用法:修饰方法和修饰代码块,下面分别分析这两种用法在对象锁和类锁上的效果。

     

    对象锁的synchronized修饰方法和代码块

    public class TestSynchronized 
        public void test1() 
            synchronized (this) 
                int i = 5;
                while (i-- > 0) 
                    System.out.println(Thread.currentThread().getName() + " : " + i);
                    try 
                        Thread.sleep(500);
                     catch (InterruptedException ie) 
                    
                
            
        
    
        public synchronized void test2() 
            int i = 5;
            while (i-- > 0) 
                System.out.println(Thread.currentThread().getName() + " : " + i);
                try 
                    Thread.sleep(500);
                 catch (InterruptedException ie) 
                
            
        
    
        public static void main(String[] args) 
            final TestSynchronized myt2 = new TestSynchronized();
            Thread test1 = new Thread(new Runnable() 
                public void run() 
                    myt2.test1();
                
            , "test1");
            Thread test2 = new Thread(new Runnable() 
                public void run() 
                    myt2.test2();
                
            , "test2");
            test1.start();
            test2.start();
        
    
    

    打印结果如下:

    test2 : 4
    test2 : 3
    test2 : 2
    test2 : 1
    test2 : 0
    test1 : 4
    test1 : 3
    test1 : 2
    test1 : 1
    test1 : 0
    

    上述的代码,第一个方法用了同步代码块的方式进行同步,传入的对象实例是this,表明是当前对象;第二个方法是修饰方法的方式进行同步。因为第一个同步代码块传入的this,所以两个同步代码所需要获得的对象锁都是同一个对象锁,下面main方法时分别开启两个线程,分别调用test1和test2方法,那么两个线程都需要获得该对象锁,另一个线程必须等待。上面也给出了运行的结果可以看到:直到test2线程执行完毕,释放掉锁,test1线程才开始执行。这里test2方法先抢到CPU资源,故它先执行,它获得了锁,它执行完毕后,test1才开始执行。

    如果我们把test2方法的synchronized关键字去掉,执行结果会如何呢? 

    test1 : 4
    test2 : 4
    test2 : 3
    test2 : 2
    test2 : 1
    test2 : 0
    test1 : 3
    test1 : 2
    test1 : 1
    test1 : 0
    

    我们可以看到,结果输出是交替着进行输出的,这是因为,某个线程得到了对象锁,但是另一个线程还是可以访问没有进行同步的方法或者代码。进行了同步的方法(加锁方法)和没有进行同步的方法(普通方法)是互不影响的,一个线程进入了同步方法,得到了对象锁,其他线程还是可以访问那些没有同步的方法(普通方法)。

     

    类锁的修饰(静态)方法和代码块  

    public class TestSynchronized 
        public void test1() 
            synchronized (TestSynchronized.class) 
                int i = 5;
                while (i-- > 0) 
                    System.out.println(Thread.currentThread().getName() + " : " + i);
                    try 
                        Thread.sleep(500);
                     catch (InterruptedException ie) 
                    
                
            
        
    
        public static synchronized void test2() 
            int i = 5;
            while (i-- > 0) 
                System.out.println(Thread.currentThread().getName() + " : " + i);
                try 
                    Thread.sleep(500);
                 catch (InterruptedException ie) 
                
            
        
    
        public static void main(String[] args) 
            final TestSynchronized myt2 = new TestSynchronized();
            Thread test1 = new Thread(new Runnable() 
                public void run() 
                    myt2.test1();
                
            , "test1");
            Thread test2 = new Thread(new Runnable() 
                public void run() 
                    TestSynchronized.test2();
                
            , "test2");
            test1.start();
            test2.start();
        
    
    

    输出结果如下:

    test1 : 4
    test1 : 3
    test1 : 2
    test1 : 1
    test1 : 0
    test2 : 4
    test2 : 3
    test2 : 2
    test2 : 1
    test2 : 0
    

    类锁修饰方法和代码块的效果和对象锁是一样的,因为类锁只是一个抽象出来的概念,只是为了区别静态方法的特点,因为静态方法是所有对象实例共用的,所以对应着synchronized修饰的静态方法的锁也是唯一的,所以抽象出来个类锁。其实这里的重点在下面这块代码,synchronized同时修饰静态和非静态方法

    public class TestSynchronized 
        public synchronized void test1() 
            int i = 5;
            while (i-- > 0) 
                System.out.println(Thread.currentThread().getName() + " : " + i);
                try 
                    Thread.sleep(500);
                 catch (InterruptedException ie) 
                
            
        
    
        public static synchronized void test2() 
            int i = 5;
            while (i-- > 0) 
                System.out.println(Thread.currentThread().getName() + " : " + i);
                try 
                    Thread.sleep(500);
                 catch (InterruptedException ie) 
                
            
        
    
        public static void main(String[] args) 
            final TestSynchronized myt2 = new TestSynchronized();
            Thread test1 = new Thread(new Runnable() 
                public void run() 
                    myt2.test1();
                
            , "test1");
            Thread test2 = new Thread(new Runnable() 
                public void run() 
                    TestSynchronized.test2();
                
            , "test2");
            test1.start();
            test2.start();
        
    
    

    输出结果如下:

    test1 : 4
    test2 : 4
    test1 : 3
    test2 : 3
    test2 : 2
    test1 : 2
    test2 : 1
    test1 : 1
    test1 : 0
    test2 : 0
    

    上面代码synchronized同时修饰静态方法和实例方法,但是运行结果是交替进行的,这证明了类锁和对象锁是两个不一样的锁,控制着不同的区域,它们是互不干扰的。同样,线程获得对象锁的同时,也可以获得该类锁,即同时获得两个锁,这是允许的。

     

    synchronized是如何保证线程安全的  

    如果有多个线程在同时运行,而这些线程可能会同时运行这段代码。程序每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。

    我们通过一个案例,演示线程的安全问题:

    我们来模拟一下火车站卖票过程,总共有100张票,总共有三个窗口卖票。

    public class SellTicket 
        public static void main(String[] args) 
            // 创建票对象
            Ticket ticket = new Ticket();
            // 创建3个窗口
            Thread t1 = new Thread(ticket, "窗口1");
            Thread t2 = new Thread(ticket, "窗口2");
            Thread t3 = new Thread(ticket, "窗口3");
            t1.start();
            t2.start();
            t3.start();
        
    
    
    // 模拟票
    class Ticket implements Runnable 
        // 共100票
        int ticket = 100;
    
        @Override
        public void run() 
            // 模拟卖票
            while (true) 
                if (ticket > 0) 
                    // 模拟选坐的操作
                    try 
                        Thread.sleep(1);
                     catch (InterruptedException e) 
                        e.printStackTrace();
                    
                    System.out.println(Thread.currentThread().getName() + "正在卖票:"
                            + ticket--);
                
            
        
    
    

    运行结果发现:上面程序出现了问题

    • 票出现了重复的票

    • 错误的票 0、-1

    其实,线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。

     

    那么出现了上述问题,我们应该如何解决呢?

    线程同步(线程安全处理Synchronized)

    java中提供了线程同步机制,它能够解决上述的线程安全问题。

    线程同步的方式有两种:

    • 方式1:同步代码块

    • 方式2:同步方法

    同步代码块

    同步代码块: 在代码块声明上 加上synchronized

    synchronized (锁对象) 
        可能会产生线程安全问题的代码
    
    

    同步代码块中的锁对象可以是任意的对象;但多个线程时,要使用同一个锁对象才能够保证线程安全。

    使用同步代码块,对火车站卖票案例中Ticket类进行如下代码修改:

    public class SellTicket 
        public static void main(String[] args) 
            // 创建票对象
            Ticket ticket = new Ticket();
            // 创建3个窗口
            Thread t1 = new Thread(ticket, "窗口1");
            Thread t2 = new Thread(ticket, "窗口2");
            Thread t3 = new Thread(ticket, "窗口3");
            t1.start();
            t2.start();
            t3.start();
        
    
    
    // 模拟票
    class Ticket implements Runnable 
        // 共100票
        int ticket = 100;
    
        Object lock = new Object();
    
        @Override
        public void run() 
            // 模拟卖票
            while (true) 
                // 同步代码块
                synchronized (lock) 
                    if (ticket > 0) 
                        // 模拟选坐的操作
                        try 
                            Thread.sleep(1);
                         catch (InterruptedException e) 
                            e.printStackTrace();
                        
                        System.out.println(Thread.currentThread().getName()
                                + "正在卖票:" + ticket--);
                    
                
            
        
    
    

    当使用了同步代码块后,上述的线程的安全问题,解决了。

     

    同步方法

    同步方法:在方法声明上加上synchronized

    public synchronized void method()
           可能会产生线程安全问题的代码
    
    

    同步方法中的锁对象是 this

    使用同步方法,对火车站卖票案例中Ticket类进行如下代码修改:

    public class SellTicket 
        public static void main(String[] args) 
            // 创建票对象
            Ticket ticket = new Ticket();
            // 创建3个窗口
            Thread t1 = new Thread(ticket, "窗口1");
            Thread t2 = new Thread(ticket, "窗口2");
            Thread t3 = new Thread(ticket, "窗口3");
            t1.start();
            t2.start();
            t3.start();
        
    
    
    // 模拟票
    class Ticket implements Runnable 
        // 共100票
        int ticket = 100;
    
        Object lock = new Object();
    
        @Override
        public void run() 
            // 模拟卖票
            while (true) 
                // 同步方法
                method();
            
        
    
        // 同步方法,锁对象this
        public synchronized void method() 
            if (ticket > 0) 
                // 模拟选坐的操作
                try 
                    Thread.sleep(10);
                 catch (InterruptedException e) 
                    e.printStackTrace();
                
                System.out.println(Thread.currentThread().getName() + "正在卖票:"
                        + ticket--);
            
        
    
    

      

    synchronized支持锁的重入吗?  

    我们先来看下面一段代码:

    public class ReentrantLockDemo 
        public synchronized void a() 
            System.out.println("a");
            b();
        
    
        private synchronized void b() 
            System.out.println("b");
        
    
        public static void main(String[] args) 
            new Thread(new Runnable() 
                @Override
                public void run() 
                    ReentrantLockDemo d = new ReentrantLockDemo();
                    d.a();
                
            ).start();
        
    
    

    上述的代码,我们分析一下,两个方法,方法a和方法b都被synchronized关键字修饰,锁对象是当前对象实例,按照上文我们对synchronized的了解,如果调用方法a,在方法a还没有执行完之前,我们是不能执行方法b的,方法a必须先释放锁,方法b才能执行,方法b处于等待状态,那样不就形成死锁了吗?那么事实真的如分析一致吗?

    运行结果发现:

    a
    b
    

    代码很快就执行完了,实验结果与分析不一致,这就引入了另外一个概念:重入锁。在 java 内部,同一线程在调用自己类中其他 synchronized 方法/块或调用父类的 synchronized 方法/块都不会阻碍该线程的执行。就是说同一线程对同一个对象锁是可重入的,而且同一个线程可以获取同一把锁多次,也就是可以多次重入。在JDK1.5后对synchronized关键字做了相关优化。

     

    synchronized死锁问题

    同步锁使用的弊端:当线程任务中出现了多个同步(多个锁)时,如果同步中嵌套了其他的同步。这时容易引发一种现象:程序出现无限等待,这种现象我们称为死锁。这种情况能避免就避免掉。

    synchronzied(A锁)
        synchronized(B锁)
        
    
    

    我们进行下死锁情况的代码演示:

    public class DeadLock 
        Object obj1 = new Object();
        Object obj2 = new Object();
    
        public void a() 
            synchronized (obj1) 
                synchronized (obj2) 
                    System.out.println("a");
                
            
        
    
        public void b() 
            synchronized (obj2) 
                synchronized (obj1) 
                    System.out.println("b");
                
            
        
    
        public static void main(String[] args) 
            DeadLock d = new DeadLock();
            new Thread(new Runnable() 
                @Override
                public void run() 
                    d.a();
                
            ).start();
    
            new Thread(new Runnable() 
                @Override
                public void run() 
                    d.b();
                
            ).start();
        
    
    

    上述的代码,我们分析一下,两个方法,我们假设两个线程T1,T2,T1运行到方法a了,拿到了obj1这把锁,此时T2运行到方法b了,拿到了obj2这把锁,T1要往下执行,就必须等待T2释放了obj2这把锁,线程T2要往下面执行,就必须等待T1释放了持有的obj1这把锁,他们两个互相等待,就形成了死锁。

    为了演示的更明白,需要让两个方法执行过程中睡眠10ms,要不然很难看到现象,因为计算机执行速度贼快

    public class DeadLock 
        Object obj1 = new Object();
        Object obj2 = new Object();
    
        public void a() 
            synchronized (obj1) 
                try 
                    Thread.sleep(10);
                 catch (InterruptedException e) 
                    e.printStackTrace();
                
                synchronized (obj2) 
                    System.out.println("a");
                
            
        
    
        public void b() 
            synchronized (obj2) 
                try 
                    Thread.sleep(10);
                 catch (InterruptedException e) 
                    e.printStackTrace();
                
                synchronized (obj1) 
                    System.out.println("b");
                
            
        
    
        public static void main(String[] args) 
            DeadLock d = new DeadLock();
            new Thread(new Runnable() 
                @Override
                public void run() 
                    d.a();
                
            ).start();
    
            new Thread(new Runnable() 
                @Override
                public void run() 
                    d.b();
                
            ).start();
        
    
    
    

    感兴趣的童鞋,下去可以试一下,程序执行不完,永远处于等待状态。

     

    总结

    • sychronized是隐式锁,是JVM底层支持的关键字,由JVM来维护;

    • 单体应用下,多线程并发操作时,使用sychronized关键字可以保证线程安全;

    • sychronized可以用来修饰方法和代码块,此时锁是当前对象实例,修饰静态方法时,锁是对象的class字节码文件;

    • 一个线程进入了sychronized修饰的同步方法,得到了对象锁,其他线程还是可以访问那些没有同步的方法(普通方法);

    • sychronized支持锁的重入;

      

    作者:Java蚂蚁

    出处:https://www.cnblogs.com/marsitman/p/11235552.html

    版权:转载请在文章明显位置注明作者及出处。

    以上是关于头条高级面试题:请谈谈Redis 9种数据结构以及它们的内部编码实现的主要内容,如果未能解决你的问题,请参考以下文章

    腾讯Java高级面试题---深圳

    BAT高级Java面试题:JVM+Redis+Spring+Mysql+数据库+多线程+算法

    Java面试题中高级,java接口的定义与语法特点

    Linux运维工程师会面试哪些

    面试题:请详细谈谈paxos算法?

    Java高级开发面试题