`man bash`, search for section _Special Parameters_. For `_` you'll find:

> expands to the last argument to the
> previous command, _after expansion_. 

So in a command line context the difference is the expansion. For example if I have a variable `FOO` set to 'bar' and I execute `echo $FOO` then `$_` on the next line will result in `bar` being submitted in the next command while `!$` will result in `$FOO`.

This behavior might not be obvious by default but with `shopt -s histverify` it's a little more evident. (With `histverify` enabled Bash will not submit history expansion immediately after <kbd>Enter</kbd>, it will first display the command, post-expansion, and wait for a second <kbd>Enter</kbd>). So we would see something like this after `FOO=bar`:

    $ echo $FOO
    $ ls !$ $_
    # We hit Enter and then see
    $ ls $FOO $_ 

`$_` is not expanded until the command is submitted during parameter expansion (along with `$FOO`). It's a subtle distinction but there you go. 

Probably the more interesting/useful distinction is that `$_` _is a variable_. So, for example, if the last parameter were `/some/file/path.txt` you could get it's length `${#_} -> 19`, extract filename only `${_##*/} -> path.txt`, change the root `${_//some/other} -> /other/file/path.txt` and so on. Can't do all of that with `!$`.


Note that `$_` has different meanings in other contexts:

>  At shell startup, set to the absolute pathname used to invoke the shell or shell script being executed as passed in  the  environment or argument list.

> Also set to the full pathname used
> to invoke each command executed and placed in the environment exported
> to that command.  


> When  checking mail, this parameter holds the name
> of the mail file currently being checked.


**UPDATE:** While mostly accurate, my answer is lacking some key information and perhaps even misleading about how/when `$_` actually gets a value. That first quote is meant to read like this:

> expands to the last argument to the previous command as it was received by that command, i.e. after going through Shell Expansion

Best way to illustrate is with a couple examples:

    $ echo "$(date +%s)" # %s is seconds since epoch
    $ echo $_  "$(date +%s)" # don't hit enter until a few seconds pass
    1536984661 1536984665

So `$_` gets the actual seconds-since-epoch calculated at the time the first `echo` executed not something closer to the `1536984665` of the second `date` invocation.

Another one:

    $ foo='a b c'
    $ echo "$foo"
    a b c
    $ echo "$_"
    a b c
    $ #---------
    $ echo $foo
    a b c
    $ echo "$_"

In the second case we didn't quote `$foo`. Before being passed to `echo` it went through variable/parameter expansion followed by word splitting. The lack of quotes resulted in "a", "b" and "c" being passed as three separate parameters and `$_` got the last, i.e. "c".