备战春招(3)

in with 0 comment

面经来自牛客
发面经涨人品(顺丰java两面)

题目

  1. volatile语意
  2. 线程同步方法
  3. 数据库索引结构,为什么快
  4. synchronize关键字
  5. jvm内存模型,有哪几块,程序计数器是干嘛的
  6. 有什么设计模式
  7. 装饰者模式讲讲
  8. Hashmap内存结构,扩容问题
  9. 创建线程的几种方式
  10. Runnable和callable区别
  11. 线程池参数, 队列,核心线程池和最大线程池关系
  12. sql注入,
  13. 哈希一致性
  14. 分布式缓存

解答

volatile语意

volatile可以保证线程的可见性和有序性。
它保证可见性是通过的操作系统原子操作Lock前缀来保证的可见性。
有序性是通过在每一个volatile读/写之前或之后插入内存屏障禁止指令重排序来保证线程的有序性。
可以看之前的一篇文章:
JAVA并发编程的艺术(7)根据JMM分析volatile

线程同步方法

有以下几种方法可以完成线程同步

可以查看我之前写的一个系列博文
Java并发

数据库索引结构,为什么快

数据库所以的使用的B+树结构来存储索引,它快的原因是因为B+树的特性。B+树是B树的变种,它是一颗多叉树,所以相对于二叉树,它整个都显得矮胖矮胖的,正是由于它多叉的特性,高度很低,在查找索引时减少了磁盘的IO次数,所以通过索引查找数据会很快。

synchronize关键字

synchronize是java的重量级锁。它主要用于同步方法或者同步代码块。实现的方式是通过Monitor对象的MonitorEntry和MonitorExit完成对象的加锁和释放锁,同时会加上内存屏障保证有序性。

jvm内存模型,有哪几块,程序计数器是干嘛的

JVM内存模型主要分为方法区、堆、虚拟栈、本地方法栈和程序计数器。其中方法区和堆是线程共享的数据区。
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

有什么设计模式

设计模式一共有23个设计模式,我研究的不多。就说下自己常用的吧。
首先是工厂模式,在之前实习的时候,有个场景需求就是通过用户输入的数据生成一张表格图片。表格分为三种不同的表,第一种是以第一列为标题的表格,第二种是以第一行为标题的表格,第三种是行和列都是标题的表格。所以使用了工厂模式,生产出三种不同格式的表格。
其次是代理模式,代理模式的作用是给已确定的方法扩展出其他方法。所以可以通过代理模式完成用户的一些操作日子记录的需求。

装饰者模式讲讲

装饰者模式,装饰的是一个对象,在这个对象原有的方法上进一步对外扩展新的方法。比如在Mybatis种,在其核心组件Executor中,他们都有一个装饰器,就是能够对缓存操作的CachingExecutor,它能够完成Mybatis操作二级缓存的过程。这就是Mybatis在装饰者模式的使用。

Hashmap内存结构,扩容问题

HashMap主要的内存结构是以数组和链表的形式,在jdk1.8,链表在长度大于等于8的时候,将会变成红黑树。
HashMap扩容的方法源码如下

final Node<K, V>[] resize() {
    // 旧数组
    Node<K, V>[] oldTab = table;
    // 旧容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 旧扩容门槛
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 如果旧容量达到了最大容量,则不再进行扩容
            threshold = Integer.MAX_VALUE;
            return oldTab;
        } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 如果旧容量的两倍小于最大容量并且旧容量大于默认初始容量(16),则容量扩大为两部,扩容门槛也扩大为两倍
            newThr = oldThr << 1; // double threshold
    } else if (oldThr > 0) // initial capacity was placed in threshold
        // 使用非默认构造方法创建的map,第一次插入元素会走到这里
        // 如果旧容量为0且旧扩容门槛大于0,则把新容量赋值为旧门槛
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        // 调用默认构造方法创建的map,第一次插入元素会走到这里
        // 如果旧容量旧扩容门槛都是0,说明还未初始化过,则初始化容量为默认容量,扩容门槛为默认容量*默认装载因子
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        // 如果新扩容门槛为0,则计算为容量*装载因子,但不能超过最大容量
        float ft = (float) newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ?
                (int) ft : Integer.MAX_VALUE);
    }
    // 赋值扩容门槛为新门槛
    threshold = newThr;
    // 新建一个新容量的数组
    @SuppressWarnings({"rawtypes", "unchecked"})
    Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
    // 把桶赋值为新数组
    table = newTab;
    // 如果旧数组不为空,则搬移元素
    if (oldTab != null) {
        // 遍历旧数组
        for (int j = 0; j < oldCap; ++j) {
            Node<K, V> e;
            // 如果桶中第一个元素不为空,赋值给e
            if ((e = oldTab[j]) != null) {
                // 清空旧桶,便于GC回收  
                oldTab[j] = null;
                // 如果这个桶中只有一个元素,则计算它在新桶中的位置并把它搬移到新桶中
                // 因为每次都扩容两倍,所以这里的第一个元素搬移到新桶的时候新桶肯定还没有元素
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    // 如果第一个元素是树节点,则把这颗树打散成两颗树插入到新桶中去
                    ((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
                else { // preserve order
                    // 如果这个链表不止一个元素且不是一颗树
                    // 则分化成两个链表插入到新的桶中去
                    // 比如,假如原来容量为4,3、7、11、15这四个元素都在三号桶中
                    // 现在扩容到8,则3和11还是在三号桶,7和15要搬移到七号桶中去
                    // 也就是分化成了两个链表
                    Node<K, V> loHead = null, loTail = null;
                    Node<K, V> hiHead = null, hiTail = null;
                    Node<K, V> next;
                    do {
                        next = e.next;
                        // (e.hash & oldCap) == 0的元素放在低位链表中
                        // 比如,3 & 4 == 0
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        } else {
                            // (e.hash & oldCap) != 0的元素放在高位链表中
                            // 比如,7 & 4 != 0
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 遍历完成分化成两个链表了
                    // 低位链表在新桶中的位置与旧桶一样(即3和11还在三号桶中)
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 高位链表在新桶中的位置正好是原来的位置加上旧容量(即7和15搬移到七号桶了)
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

总的来说就是

  1. 如果使用是默认构造方法,则第一次插入元素时初始化为默认值,容量为16,扩容门槛为12;
  2. 如果使用的是非默认构造方法,则第一次插入元素时初始化容量等于扩容门槛,扩容门槛在构造方法里等于传入容量向上最近的2的n次方;
  3. 如果旧容量大于0,则新容量等于旧容量的2倍,但不超过最大容量2的30次方,新扩容门槛为旧扩容门槛的2倍;
  4. 创建一个新容量的桶;
  5. 搬移元素,原链表分化成两个链表,低位链表存储在原来桶的位置,高位链表搬移到原来桶的位置加旧容量的位置;

这是在JDK1.8的扩容方法,整个的搬移链表到新的桶中,在JDK1.8以前,是一个一个的重新将链表元素复制进新桶中,所以在并发状态下导致链表成环,最后造成在查找元素时遍历找不到出口,造成类似死循环的情况,CPU飙到100%。

创建线程的几种方式

创建线程在我看来有四种方式

  1. 继承Thread类,重写run方法
  2. 实现Runnable方法,实现run方法
  3. 实现Callable方法,实现run方法
  4. 在JDK1.8的前提下,可以使用Lambda表达式直接创建线程

Runnable和callable区别

相同点:

不同点:

线程池参数,队列,核心线程池和最大线程池关系

线程池的定义如下:

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)  {
        ...
    }
  1. corePoolSize,核心线程池大小,执行的任务数大于线程池基本大小时就不再创建。
  2. maximumPoolSize,线程最大数量:线程池允许创建的最大线程数。
  3. keepAliveTime,线程存活时间
  4. BlockingQueue,阻塞队列,用于保存等待执行任务的阻塞队列。
  5. ThreadFactory,创建线程的工厂策略,可以通过线程工厂给每个创建出来线程设置更有意义的名字。
  6. RejectedExecutionHandler,饱和策略,一般使用的都是直接抛出异常。

sql注入

SQL注入是指通过把SQL命令插入到Web表单提交或输入域名或页面请求的查询字符串,最终达到欺骗服务器,执行恶意的SQL命令。
SQL注入就是服务端将客户端传入的恶意SQL语句直接进行了执行,这样会导致问题出现。比如说用户在登录的时候,使用了or 1=1来完成身份验证和授权。

SQL注入是一种流行的攻击攻击方法,但是通过一些手段是可以防御该攻击的,常见的防御手段如下:

哈希一致性

没听说过,百度到了答案,哈哈
深入浅出一致性Hash原理

分布式缓存

可以使用Redis、Memcached和mongoDB完成分布式缓存。一般我了解的就是Redis然后等你来问我Redis的知识,哦了。

总结

这是来自顺丰java两面的问题。1-5是一面的问题,6-14的问题是二面的问题。
1-5的问题回答起来还是问题不大。都是一些非常基础的小问题。
但是6-14,问题难度增大了一点点。比如HashMap的扩容,Runnable和Callable的区别,线程池的参数问题,这方面都是要阅读下源码才能知道结果的。还有就是安全方面的sql注入问题,也是老生常谈了。最后关于Hash一致性。。说实话。。我没听说过。哈哈。也许这就是菜吧。